From 8a5306ff88b868685ca76cfd5cdda42dca637d10 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 23:00:05 -0700 Subject: [PATCH 001/114] app-server: use permission ids and runtime workspace roots (#22611) ## Why This PR builds on [#22610](https://github.com/openai/codex/pull/22610) and is the app-server side of the migration from mutable per-turn `SandboxPolicy` replacement toward selecting immutable permission profiles by id plus mutable runtime workspace roots. Once permission profiles can carry their own immutable `workspace_roots`, app-server no longer needs to mutate the selected `PermissionProfile` just to represent thread-specific filesystem context. The mutable part now lives on the thread as explicit `runtimeWorkspaceRoots`, while `:workspace_roots` remains symbolic until the sandbox is realized for a turn. ## What Changed - Replaced the v2 permission-selection wrapper surface with plain profile ids for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start`. - Removed the API surface for profile modifications (`PermissionProfileSelectionParams`, `PermissionProfileModificationParams`, `ActivePermissionProfileModification`). - Added experimental `runtimeWorkspaceRoots` fields to the thread lifecycle and turn-start APIs. - Threaded runtime workspace roots through core session/thread snapshots, turn overrides, app-server request handling, and command execution permission resolution. - Kept session permission state symbolic so later runtime root updates and cwd-only implicit-root retargeting rebind `:workspace_roots` correctly. - Updated the embedded clients just enough to send and restore the new thread state. - Refreshed the generated schema/TypeScript artifacts and the app-server README to match the new contract. ## Verification Targeted coverage for this layer lives in: - `codex-rs/app-server-protocol/src/protocol/v2/tests.rs` - `codex-rs/app-server/tests/suite/v2/thread_start.rs` - `codex-rs/app-server/tests/suite/v2/thread_resume.rs` - `codex-rs/app-server/tests/suite/v2/turn_start.rs` - `codex-rs/core/src/session/tests.rs` The key regression checks exercise that: - `runtimeWorkspaceRoots` resolve against the effective cwd on thread start. - Profile-declared workspace roots are excluded from the runtime workspace roots returned by app-server. - A turn-level runtime workspace-root update persists onto the thread and is returned by `thread/resume`. - A named permission profile selected on one turn remains symbolic so a later runtime-root-only turn update changes the actual sandbox writes. - A cwd-only turn update retargets the implicit runtime cwd root while preserving additional runtime roots. - The protocol fixtures and generated client artifacts stay in sync with the string-based permission selection contract. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22611). * #22612 * __->__ #22611 --- .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 3 + .../schema/json/ClientRequest.json | 59 ------- .../codex_app_server_protocol.schemas.json | 59 ------- .../codex_app_server_protocol.v2.schemas.json | 59 ------- .../schema/json/v2/ThreadForkParams.json | 63 ------- .../schema/json/v2/ThreadResumeParams.json | 63 ------- .../schema/json/v2/ThreadStartParams.json | 59 ------- .../schema/json/v2/TurnStartParams.json | 59 ------- .../v2/PermissionProfileModificationParams.ts | 6 - .../v2/PermissionProfileSelectionParams.ts | 6 - .../schema/typescript/v2/index.ts | 2 - .../src/protocol/common.rs | 2 + .../src/protocol/v2/permissions.rs | 117 ++++++++++--- .../src/protocol/v2/tests.rs | 56 ++++++ .../src/protocol/v2/thread.rs | 50 +++++- .../src/protocol/v2/turn.rs | 14 +- codex-rs/app-server/README.md | 14 +- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/thread_lifecycle.rs | 2 + .../request_processors/thread_processor.rs | 30 ++++ .../thread_processor_tests.rs | 3 + .../src/request_processors/thread_summary.rs | 26 +-- .../thread_summary_tests.rs | 40 +++++ .../src/request_processors/turn_processor.rs | 64 ++++++- .../app-server/tests/suite/v2/skills_list.rs | 1 + .../tests/suite/v2/thread_resume.rs | 73 ++++++++ .../app-server/tests/suite/v2/thread_start.rs | 119 +++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 158 ++++++++++++++++- codex-rs/core/src/codex_thread.rs | 8 + codex-rs/core/src/config/config_tests.rs | 21 ++- codex-rs/core/src/config/mod.rs | 9 +- .../src/config/resolved_permission_profile.rs | 31 ---- codex-rs/core/src/session/handlers.rs | 6 + codex-rs/core/src/session/mod.rs | 11 +- codex-rs/core/src/session/session.rs | 43 ++++- codex-rs/core/src/session/tests.rs | 163 +++++++++++++++--- codex-rs/core/src/session/turn_context.rs | 8 + .../core/tests/suite/permissions_messages.rs | 7 +- codex-rs/exec/src/lib.rs | 37 ++-- codex-rs/exec/src/lib_tests.rs | 21 +-- codex-rs/protocol/src/protocol.rs | 10 ++ codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 29 +++- codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_routing.rs | 3 +- codex-rs/tui/src/app/thread_session_state.rs | 4 +- codex-rs/tui/src/app_server_session.rs | 139 ++++++++++----- codex-rs/tui/src/chatwidget/session_flow.rs | 24 +-- .../chatwidget/tests/composer_submission.rs | 9 + .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 19 +- .../tui/src/chatwidget/tests/permissions.rs | 2 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/history_cell/tests.rs | 1 + codex-rs/tui/src/session_state.rs | 21 +++ 58 files changed, 1167 insertions(+), 676 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b..7b9ab4f9d6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -201,6 +201,7 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -257,6 +258,7 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a..885875346d 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -153,6 +153,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -170,6 +171,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -187,6 +189,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6fe99b35e..f40d16feb6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1850,31 +1850,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -1886,40 +1861,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", 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 603b08d343..e37c26881b 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 @@ -11739,31 +11739,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11775,40 +11750,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", 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 187650c913..653a1a5751 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 @@ -8288,31 +8288,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8324,40 +8299,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd..102cfa0299 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -64,65 +60,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149..27674afc7b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -298,65 +294,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9a60049a61..99b25490ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 1ef33d4301..086d3c6ed1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -114,65 +114,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; 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 0a6d868ad0..beb1973bb9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -257,9 +257,7 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 89f60f979b..e0cd330fff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2296,6 +2296,7 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, @@ -2340,6 +2341,7 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "runtimeWorkspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 0796ee4e89..faf264411e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -21,7 +21,9 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use std::num::NonZeroUsize; use std::path::PathBuf; use ts_rs::TS; @@ -456,31 +458,100 @@ impl From for CoreActivePermissionProfile { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSelectionParams { + id: String, + legacy_additional_writable_roots: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, +impl PermissionProfileSelectionParams { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + legacy_additional_writable_roots: Vec::new(), + } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn into_id(self) -> String { + self.id + } + + pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] { + &self.legacy_additional_writable_roots + } +} + +impl From for PermissionProfileSelectionParams { + fn from(id: String) -> Self { + Self::new(id) + } +} + +impl Serialize for PermissionProfileSelectionParams { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.id) + } +} + +impl<'de> Deserialize<'de> for PermissionProfileSelectionParams { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Id(String), + LegacyProfile { + #[serde(rename = "type")] + _type: LegacyPermissionProfileSelectionType, + id: String, + #[serde(default)] + modifications: Option>, + }, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + enum LegacyPermissionProfileSelectionType { + Profile, + } + + #[derive(Deserialize)] + #[serde(tag = "type", rename_all = "camelCase")] + enum LegacyPermissionProfileModificationParams { + #[serde(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, + } + + match Wire::deserialize(deserializer)? { + Wire::Id(id) => Ok(Self::new(id)), + Wire::LegacyProfile { + id, modifications, .. + } => { + let legacy_additional_writable_roots = modifications + .unwrap_or_default() + .into_iter() + .map(|modification| match modification { + LegacyPermissionProfileModificationParams::AdditionalWritableRoot { + path, + } => path, + }) + .collect(); + Ok(Self { + id, + legacy_additional_writable_roots, + }) + } + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index f32c483ec7..50058cb686 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -655,6 +655,61 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { assert_eq!(response.strict_auto_review, Some(true)); } +#[test] +fn permission_profile_selection_accepts_legacy_object_shape() { + let additional_root = absolute_path("additional-root"); + let params = json!({ + "permissions": { + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": additional_root, + } + ], + }, + }); + + let start: ThreadStartParams = + serde_json::from_value(params.clone()).expect("thread/start params deserialize"); + assert_legacy_permission_profile_selection(start.permissions, &additional_root); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/resume params deserialize"); + assert_legacy_permission_profile_selection(resume.permissions, &additional_root); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/fork params deserialize"); + assert_legacy_permission_profile_selection(fork.permissions, &additional_root); + + let turn: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": params["permissions"].clone(), + })) + .expect("turn/start params deserialize"); + assert_legacy_permission_profile_selection(turn.permissions, &additional_root); +} + +fn assert_legacy_permission_profile_selection( + selection: Option, + additional_root: &AbsolutePathBuf, +) { + let selection = selection.expect("permissions should be present"); + assert_eq!(selection.id(), ":workspace"); + assert_eq!( + selection.legacy_additional_writable_roots(), + std::slice::from_ref(additional_root) + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { @@ -3473,6 +3528,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 458722b3a2..a3321436f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,6 +107,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -116,10 +121,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -195,6 +200,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -264,6 +274,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -273,10 +288,11 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the resumed thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/resume.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -310,6 +326,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -370,6 +391,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -379,10 +405,11 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the forked thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/fork.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -419,6 +446,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, 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 61a09bfbf5..8f4cd04e2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -64,6 +64,12 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots for this turn and + /// subsequent turns. Relative paths are resolved against the effective + /// cwd for the turn. + #[experimental("turn/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -75,11 +81,11 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, /// Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index bd7dd186fe..09c450614f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,10 +130,10 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots, `permissionProfile` for the exact active runtime permissions, and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -156,7 +156,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -236,7 +236,9 @@ Start a fresh thread when you need a new Codex conversation. "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -649,7 +651,9 @@ You can optionally specify config overrides on the new turn. If specified, these "networkAccess": true }, // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index c955d06ba2..a9625d3086 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -659,6 +659,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { }], responsesapi_client_metadata: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2c20c1816a..611678a713 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; 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 7dab206d85..c64066ee93 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -604,6 +604,7 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; @@ -620,6 +621,7 @@ pub(super) async fn handle_pending_thread_resume_request( model_provider: model_provider_id, service_tier, cwd, + runtime_workspace_roots: workspace_roots, instruction_sources, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), 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 85b2e57571..3038feeef1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -59,6 +59,25 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { + let base_cwd = request + .cwd + .as_deref() + .map(|cwd| { + AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) + }) + .unwrap_or_else(|| config_snapshot.cwd.clone()); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots + .iter() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) + .collect::>(); + if requested_runtime_workspace_roots != config_snapshot.workspace_roots { + mismatch_details.push(format!( + "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", + config_snapshot.workspace_roots + )); + } + } if let Some(requested_approval) = request.approval_policy.as_ref() { let active_approval: AskForApproval = config_snapshot.approval_policy.into(); if requested_approval != &active_approval { @@ -804,6 +823,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -837,6 +857,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1173,6 +1194,7 @@ impl ThreadRequestProcessor { model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, cwd: config_snapshot.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), @@ -1214,6 +1236,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1227,6 +1250,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: runtime_workspace_roots, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -2351,6 +2375,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2386,6 +2411,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2523,6 +2549,7 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), @@ -2987,6 +3014,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3052,6 +3080,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3181,6 +3210,7 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), 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 f59daab2fb..c2e6b9a55e 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 @@ -636,6 +636,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -656,6 +657,8 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), + profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, personality: None, 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 875bd3deaf..63ceeb55e2 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -179,19 +179,23 @@ pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(selection) = permissions else { return; }; - overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); + overrides.default_permissions = Some(selection.id().to_string()); + if selection.legacy_additional_writable_roots().is_empty() { + return; + } + + let legacy_roots = selection + .legacy_additional_writable_roots() + .iter() + .map(AbsolutePathBuf::to_path_buf); + if let Some(workspace_roots) = overrides.workspace_roots.as_mut() { + workspace_roots.extend(legacy_roots); + } else { + overrides.additional_writable_roots.extend(legacy_roots); + } } pub(super) fn thread_response_sandbox_policy( diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d..699db34ed4 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -66,3 +66,43 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { assert_eq!(summary, expected); Ok(()) } + +#[test] +fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> { + let root = if cfg!(windows) { + AbsolutePathBuf::try_from("C:\\workspace-extra")? + } else { + AbsolutePathBuf::try_from("/workspace-extra")? + }; + let selection = serde_json::from_value::(json!({ + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": root, + } + ], + }))?; + + let mut overrides = ConfigOverrides::default(); + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone())); + assert_eq!( + overrides.default_permissions, + Some(":workspace".to_string()) + ); + assert_eq!( + overrides.additional_writable_roots, + vec![root.to_path_buf()] + ); + + let mut overrides = ConfigOverrides { + workspace_roots: Some(Vec::new()), + ..ConfigOverrides::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection)); + assert_eq!(overrides.additional_writable_roots, Vec::::new()); + assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()])); + + Ok(()) +} 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 110406cc9e..54fc0383e4 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -16,6 +16,20 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } +fn resolve_runtime_workspace_roots( + workspace_roots: Vec, + base_cwd: &AbsolutePathBuf, +) -> Vec { + let mut resolved_roots = Vec::new(); + for path in workspace_roots { + let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -355,8 +369,16 @@ impl TurnRequestProcessor { .map(V2UserInput::into_core) .collect(); let turn_has_input = !mapped_items.is_empty(); + let runtime_workspace_roots_request = params.runtime_workspace_roots.clone(); + let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some() + { + Some(thread.config_snapshot().await) + } else { + None + }; let has_any_overrides = params.cwd.is_some() + || runtime_workspace_roots_request.is_some() || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() @@ -375,16 +397,45 @@ impl TurnRequestProcessor { } let cwd = params.cwd; + let runtime_workspace_roots = if let Some(workspace_roots) = + runtime_workspace_roots_request.clone() + { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error( + "turn/start runtime workspace roots missing thread snapshot", + )); + }; + let base_cwd = cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) + .unwrap_or_else(|| snapshot.cwd.clone()); + Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) + } else { + None + }; let approval_policy = params.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = params .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = + let (permission_profile, active_permission_profile, profile_workspace_roots) = if let Some(permissions) = params.permissions { - let snapshot = thread.config_snapshot().await; + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error( + "turn/start permission selection missing thread snapshot", + )); + }; let mut overrides = ConfigOverrides { cwd: cwd.clone(), + workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( + || { + snapshot + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + }, + )), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() @@ -413,11 +464,12 @@ impl TurnRequestProcessor { ))); } ( - Some(config.permissions.effective_permission_profile()), + Some(config.permissions.permission_profile().clone()), config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), ) } else { - (None, None) + (None, None, None) }; let model = params.model; let effort = params.effort.map(Some); @@ -432,11 +484,13 @@ impl TurnRequestProcessor { thread .validate_turn_context_overrides(CodexThreadTurnContextOverrides { cwd: cwd.clone(), + workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), permission_profile: permission_profile.clone(), active_permission_profile: active_permission_profile.clone(), + profile_workspace_roots: profile_workspace_roots.clone(), windows_sandbox_level: None, model: model.clone(), effort, @@ -457,6 +511,8 @@ impl TurnRequestProcessor { final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..db982bc5d0 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -614,6 +614,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( model_provider: None, service_tier: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 64dfe0beb7..ff2ccec49c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -184,6 +184,79 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let extra_root_tmp = TempDir::new()?; + let extra_root = extra_root_tmp.path().join("extra-root"); + std::fs::create_dir_all(&extra_root)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + runtime_workspace_roots, + .. + } = to_response::(resume_resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![AbsolutePathBuf::from_absolute_path(extra_root)?] + ); + + Ok(()) +} + #[tokio::test] async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 78155d8c9a..75c124e893 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -236,6 +236,89 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let cwd_tmp = TempDir::new()?; + let cwd = cwd_tmp.path().to_path_buf(); + let relative_root = PathBuf::from("extra-root"); + std::fs::create_dir_all(cwd.join(&relative_root))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some(vec![relative_root.clone()]), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + cwd: response_cwd, + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!(response_cwd, cwd.abs()); + assert_eq!( + runtime_workspace_roots, + vec![cwd_tmp.path().join(relative_root).abs()] + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_roots() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let profile_root = TempDir::new()?; + create_config_toml_with_profile_workspace_root( + codex_home.path(), + &server.uri(), + profile_root.path(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.path().to_string_lossy().to_string()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![cwd.path().to_path_buf().abs()] + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -980,6 +1063,42 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_profile_workspace_root( + codex_home: &Path, + server_uri: &str, + profile_root: &Path, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let profile_root_key = profile_root + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.workspace_roots] +"{profile_root_key}" = true + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"#, + ), + ) +} + fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 314be8a6ff..afc37d73c4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -780,10 +779,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - modifications: None, - }), + permissions: Some( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS + .to_string() + .into(), + ), ..Default::default() }) .await?; @@ -1891,6 +1891,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(first_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { @@ -1932,6 +1933,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(second_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), @@ -1991,6 +1993,152 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let old_root = tmp.path().join("old-root"); + let new_root = tmp.path().join("new-root"); + std::fs::create_dir(&old_root)?; + std::fs::create_dir(&new_root)?; + let old_root_text = old_root.to_string_lossy().into_owned(); + let new_root_text = new_root.to_string_lossy().into_owned(); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done first"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "done second"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let server_uri = server.uri(); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"# + ), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "select dev profile".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![old_root]), + permissions: Some("dev".to_string().into()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "write in new root".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![new_root]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two Responses API requests"); + let latest_permissions_instructions = + |request: &core_test_support::responses::ResponsesRequest| { + request + .message_input_texts("developer") + .into_iter() + .rev() + .find(|text| text.contains("")) + .expect("permissions instructions") + }; + let first_permissions = latest_permissions_instructions(&requests[0]); + assert!(first_permissions.contains(&old_root_text)); + assert!( + !first_permissions.contains(&new_root_text), + "first turn should materialize the initial runtime workspace root" + ); + + let second_permissions = latest_permissions_instructions(&requests[1]); + assert!(second_permissions.contains(&new_root_text)); + assert!( + !second_permissions.contains(&old_root_text), + "second turn should rebind :workspace_roots to the updated runtime workspace root" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index be5b0ccb88..1a2d5ed710 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -59,6 +59,8 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, + pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, @@ -82,6 +84,8 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, + pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -258,6 +262,8 @@ impl CodexThread { ) -> ConstraintResult<()> { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -283,6 +289,8 @@ impl CodexThread { let updates = SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6412bff01e..f197ccad71 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1905,9 +1905,28 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( ) .await?; + let cwd_abs = cwd.abs(); + let runtime_root_abs = runtime_root.abs(); let profile_root_abs = profile_root.abs(); + assert_eq!( + config.workspace_roots, + vec![cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.permissions.workspace_roots(), + &[cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.effective_workspace_roots(), + vec![ + cwd_abs.clone(), + runtime_root_abs.clone(), + profile_root_abs.clone() + ] + ); + let policy = config.permissions.file_system_sandbox_policy(); - for root in [cwd.abs(), runtime_root.abs(), profile_root_abs.clone()] { + for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] { assert!( policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), "expected workspace root to be writable, policy: {policy:?}" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d9f744155d..527c93b3f3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1231,6 +1231,13 @@ impl Config { Ok(()) } + pub fn effective_workspace_roots(&self) -> Vec { + let mut workspace_roots = self.workspace_roots.clone(); + workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + workspace_roots + } + pub fn to_models_manager_config(&self) -> ModelsManagerConfig { ModelsManagerConfig { model_context_window: self.model_context_window, @@ -2671,8 +2678,6 @@ impl Config { configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); } dedupe_absolute_paths(&mut configured_workspace_roots); - workspace_roots.extend(configured_workspace_roots.iter().cloned()); - dedupe_absolute_paths(&mut workspace_roots); file_system_sandbox_policy = file_system_sandbox_policy .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); let mut permission_profile = if let Some(permission_profile) = diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs index c7cf264dae..911d9bf2e8 100644 --- a/codex-rs/core/src/config/resolved_permission_profile.rs +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -101,24 +101,6 @@ impl ResolvedPermissionProfile { } } - fn with_permission_profile(&self, permission_profile: PermissionProfile) -> Self { - match self { - Self::Legacy(_) => Self::legacy(permission_profile), - Self::BuiltIn(profile) => Self::BuiltIn(BuiltInPermissionProfile { - id: profile.id, - extends: profile.extends.clone(), - permission_profile, - profile_workspace_roots: profile.profile_workspace_roots.clone(), - }), - Self::Named(profile) => Self::Named(NamedPermissionProfile { - id: profile.id.clone(), - extends: profile.extends.clone(), - permission_profile, - profile_workspace_roots: profile.profile_workspace_roots.clone(), - }), - } - } - pub(crate) fn active_permission_profile(&self) -> Option { match self { Self::Legacy(_) => None, @@ -189,19 +171,6 @@ impl PermissionProfileState { self.resolved_permission_profile.get().permission_profile() } - pub(crate) fn clone_with_permission_profile( - &self, - permission_profile: PermissionProfile, - ) -> ConstraintResult { - let candidate = self - .resolved_permission_profile - .get() - .with_permission_profile(permission_profile); - let mut state = self.clone(); - state.resolved_permission_profile.set(candidate)?; - Ok(state) - } - pub(crate) fn active_permission_profile(&self) -> Option { self.resolved_permission_profile .get() diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5cb37f9828..a26b9bb3f6 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -146,6 +146,8 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), + workspace_roots: None, + profile_workspace_roots: None, permission_profile, active_permission_profile: None, windows_sandbox_level: None, @@ -163,6 +165,8 @@ pub(super) async fn user_input_or_turn_inner( } Op::UserInputWithTurnContext { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -195,6 +199,8 @@ pub(super) async fn user_input_or_turn_inner( items, SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1938ae2a81..142e39ea97 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -621,6 +621,7 @@ impl Codex { permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: environment_selections.to_selections(), @@ -821,15 +822,7 @@ fn get_service_tier( fn session_permission_profile_state_from_config( config: &Config, ) -> CodexResult { - config - .permissions - .permission_profile_state() - .clone_with_permission_profile(config.permissions.effective_permission_profile()) - .map_err(|err| { - CodexErr::Fatal(format!( - "failed to materialize workspace roots for session permissions: {err}" - )) - }) + Ok(config.permissions.permission_profile_state().clone()) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5f140ee36d..5b57e11895 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -63,9 +63,9 @@ pub(crate) struct SessionConfiguration { /// When to escalate for approval for execution pub(super) approval_policy: Constrained, pub(super) approvals_reviewer: ApprovalsReviewer, - /// Permission profile state for the session. Keep the constrained profile - /// and selected profile id in sync by using the methods below instead of - /// mutating the fields independently. + /// Permission profile state for the session. Keep the constrained profile, + /// active profile id, and profile-defined workspace roots in sync by using + /// the methods below instead of mutating the fields independently. pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, @@ -74,6 +74,9 @@ pub(crate) struct SessionConfiguration { /// execution sandbox are resolved against this directory **instead** of /// the process-wide current working directory. pub(super) cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots for materializing symbolic + /// workspace permissions at session runtime. + pub(super) workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -107,13 +110,20 @@ impl SessionConfiguration { } pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile_state.permission_profile().clone() + self.permission_profile_state + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) } pub(super) fn active_permission_profile(&self) -> Option { self.permission_profile_state.active_permission_profile() } + pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + pub(super) fn apply_permission_profile_to_permissions( &self, permissions: &mut crate::config::Permissions, @@ -164,6 +174,8 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), + profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -243,6 +255,23 @@ impl SessionConfiguration { let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; + 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) { + 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() + } else { + root.clone() + }; + if !retargeted_workspace_roots.contains(&root) { + retargeted_workspace_roots.push(root); + } + } + next_configuration.workspace_roots = retargeted_workspace_roots; + } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = @@ -256,6 +285,7 @@ impl SessionConfiguration { next_configuration.set_permission_profile_projection( permission_profile, active_permission_profile, + updates.profile_workspace_roots.clone().unwrap_or_default(), Some(¤t_file_system_sandbox_policy), )?; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { @@ -311,6 +341,7 @@ impl SessionConfiguration { &mut self, permission_profile: PermissionProfile, active_permission_profile: Option, + profile_workspace_roots: Vec, preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>, ) -> ConstraintResult<()> { let enforcement = permission_profile.enforcement(); @@ -329,7 +360,7 @@ impl SessionConfiguration { self.permission_profile_state.set_active_permission_profile( effective_permission_profile, active_permission_profile, - Vec::new(), + profile_workspace_roots, ) } } @@ -337,6 +368,8 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, + pub(crate) workspace_roots: Option>, + pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 81012419cb..9265072dde 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -36,6 +36,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; @@ -2153,29 +2154,47 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_permission_profile_materializes_runtime_workspace_roots() -> anyhow::Result<()> { +async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow::Result<()> { let codex_home = tempfile::TempDir::new()?; let cwd = tempfile::TempDir::new()?; - let extra_root = tempfile::TempDir::new()?; + let old_root = test_path_buf("/workspace/old").abs(); + let new_root = test_path_buf("/workspace/new").abs(); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(crate::config::ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), - additional_writable_roots: vec![extra_root.path().to_path_buf()], + additional_writable_roots: vec![old_root.to_path_buf()], ..Default::default() }) .build() .await?; + let session_permission_profile_state = session_permission_profile_state_from_config(&config)?; - let file_system_policy = session_permission_profile_state + let stored_file_system_policy = session_permission_profile_state .permission_profile() .file_system_sandbox_policy(); - assert!( - file_system_policy.can_write_path_with_cwd(extra_root.path(), config.cwd.as_path()), - "session permission profile should carry materialized runtime workspace roots" + !stored_file_system_policy + .can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()), + "session permission profile state should keep runtime workspace roots symbolic" ); + + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.cwd = config.cwd.clone(); + session_configuration.workspace_roots = config.workspace_roots.clone(); + session_configuration.permission_profile_state = session_permission_profile_state; + + let initial_policy = session_configuration.file_system_sandbox_policy(); + assert!(initial_policy.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path())); + + let updated = session_configuration.apply(&SessionSettingsUpdate { + workspace_roots: Some(vec![new_root.clone()]), + ..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())); Ok(()) } @@ -2917,6 +2936,7 @@ async fn set_rate_limits_retains_previous_credits() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3020,6 +3040,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3492,6 +3513,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3561,6 +3583,8 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd ), ) .expect("set permission profile"); + let expected_file_system_sandbox_policy = file_system_sandbox_policy + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -3571,7 +3595,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd assert_eq!( updated.file_system_sandbox_policy(), - file_system_sandbox_policy + expected_file_system_sandbox_policy ); } @@ -3620,7 +3644,8 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ }) .expect("permission profile update should succeed"); - let mut expected_file_system_policy = requested_file_system_policy; + let mut expected_file_system_policy = requested_file_system_policy + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -3675,6 +3700,93 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo ); } +#[tokio::test] +async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() { + let mut session_configuration = make_session_configuration_for_tests().await; + let old_root = tempfile::tempdir().expect("create old root"); + let new_root = tempfile::tempdir().expect("create new root"); + let profile_root = tempfile::tempdir().expect("create profile root"); + let old_root = old_root.path().abs(); + let new_root = new_root.path().abs(); + let profile_root = profile_root.path().abs(); + session_configuration.workspace_roots = vec![old_root.clone()]; + + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + workspace_roots: Some(vec![new_root.clone()]), + permission_profile: Some(permission_profile), + active_permission_profile: Some(ActivePermissionProfile::new("dev")), + profile_workspace_roots: Some(vec![profile_root.clone()]), + ..Default::default() + }) + .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_eq!( + updated.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) + ); + assert_eq!(updated.profile_workspace_roots(), &[profile_root]); +} + +#[tokio::test] +async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let old_root = tempfile::tempdir().expect("create old root"); + let new_root = tempfile::tempdir().expect("create new root"); + let extra_root = tempfile::tempdir().expect("create extra root"); + 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.workspace_roots = vec![old_root.clone(), extra_root.clone()]; + + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + session_configuration + .set_permission_profile_for_tests(permission_profile) + .expect("set permission profile"); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(new_root.to_path_buf()), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.workspace_roots, + 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())); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { @@ -3762,12 +3874,13 @@ enabled = false } #[tokio::test] -async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() { +async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_update() { let mut session_configuration = make_session_configuration_for_tests().await; let workspace = tempfile::tempdir().expect("create temp dir"); - let project_root = workspace.path().join("project"); - let original_cwd = project_root.join("subdir"); - session_configuration.cwd = original_cwd.abs(); + 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()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -3790,20 +3903,23 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.clone()), + cwd: Some(project_root.to_path_buf()), ..Default::default() }) .expect("cwd-only update should succeed"); - let expected_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &updated.sandbox_policy(), - &project_root, - ); + assert_eq!(updated.workspace_roots, vec![project_root.clone()]); assert!( updated .file_system_sandbox_policy() - .is_semantically_equivalent_to(&expected_file_system_policy, &project_root), - "cwd-only update should rederive the legacy filesystem policy for the new cwd" + .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()), + "cwd-only update should not keep the old implicit cwd writable" ); } @@ -4032,6 +4148,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -4140,6 +4257,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4373,6 +4491,7 @@ async fn make_session_with_config_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4475,6 +4594,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -5124,6 +5244,8 @@ fn op_kind_distinguishes_turn_ops() { final_output_json_schema: None, responsesapi_client_metadata: None, cwd: None, + workspace_roots: None, + profile_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -5991,6 +6113,7 @@ where permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index eefa6bc9da..cab18b8561 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -432,6 +432,10 @@ impl Session { let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); per_turn_config.cwd = cwd; + per_turn_config.workspace_roots = session_configuration.workspace_roots.clone(); + per_turn_config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -466,6 +470,10 @@ impl Session { 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(); + config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); config } diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 05fbaad89f..c4f1bef968 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -557,6 +557,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .set_permission_profile(permission_profile) .expect("test permission profile should be allowed"); let workspace_roots = vec![config.cwd.clone(), writable_root_for_config]; + config.workspace_roots = workspace_roots.clone(); config.permissions.set_workspace_roots(workspace_roots); config.config_layer_stack = ConfigLayerStack::default(); }); @@ -578,9 +579,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let permissions = permissions_texts(&req.single_request()); let normalize_line_endings = |s: &str| s.replace("\r\n", "\n"); let exec_policy = load_exec_policy(&test.config.config_layer_stack).await?; - let sandbox_policy = test.config.legacy_sandbox_policy(); - let expected = PermissionsInstructions::from_policy( - &sandbox_policy, + let permission_profile = test.config.permissions.effective_permission_profile(); + let expected = PermissionsInstructions::from_permission_profile( + &permission_profile, AskForApproval::OnRequest, test.config.approvals_reviewer, &exec_policy, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a71ca5fa3f..9f7174ec68 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -790,6 +789,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { responsesapi_client_metadata: None, environments: None, cwd: Some(default_cwd), + runtime_workspace_roots: None, approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, @@ -961,6 +961,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -984,6 +991,13 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -997,30 +1011,13 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = workspace_roots - .iter() - .filter(|root| root.as_path() != cwd) - .cloned() - .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 2aa2663435..367bba5d05 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -459,24 +459,14 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() } #[test] -fn active_profile_selection_includes_extra_workspace_roots_as_modifications() { - let cwd = test_path_buf("/workspace/project").abs(); - let extra_root = test_path_buf("/workspace/cache").abs(); - - let selection = permissions_selection_from_active_profile( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), - cwd.as_path(), - &[cwd.clone(), extra_root.clone()], - ); +fn active_profile_selection_uses_profile_id_only() { + let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )); assert_eq!( selection, - PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - modifications: Some(vec![ - PermissionProfileModificationParams::AdditionalWritableRoot { path: extra_root } - ]), - } + PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) ); } @@ -583,6 +573,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d858..fac2616d04 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -462,6 +462,16 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] cwd: Option, + /// Updated runtime workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions. + #[serde(skip_serializing_if = "Option::is_none")] + workspace_roots: Option>, + + /// Updated profile-defined workspace roots for status summaries and + /// per-turn config reconstruction. + #[serde(skip_serializing_if = "Option::is_none")] + profile_workspace_roots: Option>, + /// Updated command approval policy. #[serde(skip_serializing_if = "Option::is_none")] approval_policy: Option, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d77e3b3140..8d02e6f1e4 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -661,6 +661,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: next_cwd.clone().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index dac290e1a5..bbc1802d17 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2796,10 +2796,13 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); + let shared_root = test_path_buf("/tmp/shared").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); @@ -2871,6 +2874,10 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re assert_eq!(session.model_provider_id, "agent-provider"); assert_eq!(session.approval_policy, primary_session.approval_policy); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path()); + assert_eq!( + session.runtime_workspace_roots, + vec![test_path_buf("/tmp/agent").abs(), shared_root] + ); assert_eq!(session.rollout_path, Some(rollout_path)); assert_eq!( app.agent_navigation.get(&agent_thread_id), @@ -2892,10 +2899,12 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); @@ -2962,10 +2971,12 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread"); let read_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_session_configured = Some(primary_session); @@ -2997,6 +3008,10 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { assert_eq!(session.thread_id, read_thread_id); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); + assert_eq!( + session.runtime_workspace_roots, + vec![test_path_buf("/tmp/read").abs()] + ); let expected_permission_profile = app .chat_widget .config_ref() @@ -3688,6 +3703,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), message_history: None, @@ -3936,6 +3952,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4511,6 +4528,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4574,6 +4592,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4666,6 +4685,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4901,6 +4921,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { )]; let resumed_session = ThreadSessionState { cwd: test_path_buf("/tmp/refreshed").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), ..initial_session.clone() }; @@ -5065,6 +5086,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -5186,6 +5208,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 431bf5f804..30f68dc640 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -352,6 +352,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 9d6cda012d..716f2eedf0 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -895,7 +895,8 @@ impl App { session.thread_id = thread_id; session.thread_name = notification.thread.name.clone(); session.model_provider_id = notification.thread.model_provider.clone(); - session.cwd = notification.thread.cwd.clone(); + session + .set_cwd_retargeting_implicit_runtime_workspace_root(notification.thread.cwd.clone()); let rollout_path = notification.thread.path.clone(); if let Some(model) = read_session_model(self.state_db.as_deref(), thread_id, rollout_path.as_deref()).await diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 6afbc2de38..a8d421563b 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -72,6 +72,7 @@ impl App { permission_profile: permission_profile.clone(), active_permission_profile: active_permission_profile.clone(), cwd: thread.cwd.clone(), + runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), message_history: None, @@ -81,7 +82,7 @@ impl App { session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); - session.cwd = thread.cwd.clone(); + session.set_cwd_retargeting_implicit_runtime_workspace_root(thread.cwd.clone()); session.permission_profile = permission_profile; session.active_permission_profile = active_permission_profile; session.instruction_source_paths = Vec::new(); @@ -148,6 +149,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 6a0af955ba..0470d24db6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; @@ -577,6 +576,12 @@ impl AppServerSession { responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), + runtime_workspace_roots: Some( + workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1175,34 +1180,22 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, - cwd: &std::path::Path, - workspace_roots: &[AbsolutePathBuf], ) -> PermissionProfileSelectionParams { - let modifications = workspace_roots - .iter() - .filter(|root| root.as_path() != cwd) - .cloned() - .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn turn_permissions_overrides( permission_profile: &PermissionProfile, active_permission_profile: Option, cwd: &std::path::Path, - workspace_roots: &[AbsolutePathBuf], + _workspace_roots: &[AbsolutePathBuf], thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile - .map(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots)) + active_permission_profile.map(permissions_selection_from_active_profile) } else { None }; @@ -1231,13 +1224,7 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(|active| { - permissions_selection_from_active_profile( - active, - config.cwd.as_path(), - config.permissions.user_visible_workspace_roots(), - ) - }) + .map(permissions_selection_from_active_profile) } fn thread_start_params_from_config( @@ -1261,6 +1248,13 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1296,6 +1290,13 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1328,6 +1329,13 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1425,6 +1433,7 @@ async fn thread_session_state_from_thread_start_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1457,6 +1466,7 @@ async fn thread_session_state_from_thread_resume_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1489,6 +1499,7 @@ async fn thread_session_state_from_thread_fork_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1531,6 +1542,7 @@ async fn thread_session_state_from_thread_response( permission_profile: PermissionProfile, active_permission_profile: Option, cwd: AbsolutePathBuf, + runtime_workspace_roots: Vec, instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, @@ -1558,6 +1570,7 @@ async fn thread_session_state_from_thread_response( permission_profile, active_permission_profile, cwd, + runtime_workspace_roots, instruction_source_paths, reasoning_effort, message_history: Some(MessageHistoryMetadata { @@ -1637,19 +1650,23 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!( + params.runtime_workspace_roots, + Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + ) + ); assert_eq!(params.sandbox, None); assert_eq!( params.permissions, config .permissions .active_permission_profile() - .map(|active| { - permissions_selection_from_active_profile( - active, - config.cwd.as_path(), - config.permissions.user_visible_workspace_roots(), - ) - }) + .map(permissions_selection_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1676,11 +1693,8 @@ mod tests { let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); let workspace_roots = vec![cwd.clone()]; - let expected_permissions = permissions_selection_from_active_profile( - active_permission_profile.clone(), - cwd.as_path(), - &workspace_roots, - ); + let expected_permissions = + permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), @@ -1695,12 +1709,12 @@ mod tests { } #[test] - fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() { + fn embedded_turn_permissions_select_profile_id_only() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let workspace_roots = vec![cwd.clone(), extra_root.clone()]; + let workspace_roots = vec![cwd.clone(), extra_root]; let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), @@ -1713,14 +1727,9 @@ mod tests { assert_eq!(sandbox_policy, None); assert_eq!( permissions, - Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - modifications: Some(vec![ - PermissionProfileModificationParams::AdditionalWritableRoot { - path: extra_root - } - ]), - }) + Some(PermissionProfileSelectionParams::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + )) ); } @@ -1777,6 +1786,13 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); + let expected_runtime_workspace_roots = Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>(), + ); let start = thread_start_params_from_config( &config, @@ -1800,6 +1816,18 @@ mod tests { assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); + assert_eq!( + start.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + resume.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + fork.runtime_workspace_roots, + expected_runtime_workspace_roots + ); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); @@ -2070,6 +2098,10 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: vec![ + test_path_buf("/tmp/project").abs(), + test_path_buf("/tmp/project/extra").abs(), + ], instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, @@ -2090,6 +2122,10 @@ mod tests { .await .expect("resume response should map"); assert_eq!(started.session.forked_from_id, Some(forked_from_id)); + assert_eq!( + started.session.runtime_workspace_roots, + response.runtime_workspace_roots + ); assert_eq!( started.session.instruction_source_paths, response.instruction_sources @@ -2097,6 +2133,17 @@ mod tests { assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); + + let mut empty_roots_response = response; + empty_roots_response.runtime_workspace_roots = Vec::new(); + let started = started_thread_from_resume_response( + empty_roots_response, + &config, + ThreadParamsMode::Remote, + ) + .await + .expect("resume response should map"); + assert_eq!(started.session.runtime_workspace_roots, Vec::new()); } #[tokio::test] @@ -2193,6 +2240,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) @@ -2227,6 +2275,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 932263bcc1..5dfb2090f7 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -32,26 +32,12 @@ impl ChatWidget { self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); - let previous_cwd = self.config.cwd.clone(); - let previous_workspace_roots = self.config.workspace_roots.clone(); self.config.cwd = session.cwd.clone(); - if !self.config.workspace_roots_explicit { - let mut workspace_roots = vec![session.cwd.clone()]; - if previous_workspace_roots - .iter() - .any(|root| root == &previous_cwd) - { - for root in previous_workspace_roots { - if root != previous_cwd - && !workspace_roots.iter().any(|existing| existing == &root) - { - workspace_roots.push(root); - } - } - } - self.config.workspace_roots = workspace_roots.clone(); - self.config.permissions.set_workspace_roots(workspace_roots); - } + let runtime_workspace_roots = session.runtime_workspace_roots.clone(); + self.config.workspace_roots = runtime_workspace_roots.clone(); + self.config + .permissions + .set_workspace_roots(runtime_workspace_roots); self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 95e8317e9c..96477c8d38 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -28,6 +28,7 @@ async fn submission_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -131,6 +132,7 @@ async fn submission_includes_configured_permission_profile() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -180,6 +182,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -221,6 +224,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -314,6 +318,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -377,6 +382,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -415,6 +421,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -453,6 +460,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -494,6 +502,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 2655c8cf95..d54783615c 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -957,6 +957,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 6e3ead97bb..8282430306 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -29,6 +29,7 @@ async fn resumed_initial_messages_render_history() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -99,6 +100,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -167,6 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -266,6 +269,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: expected_cwd.clone(), + runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -300,7 +304,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { assert_eq!( chat.config_ref().permissions.effective_permission_profile(), updated_profile - .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)), + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd,)), "effective permissions should still use the current thread runtime workspace roots" ); } @@ -319,9 +323,10 @@ async fn session_configured_preserves_profile_workspace_roots() { .set_workspace_roots(chat.config.workspace_roots.clone()); let session_cwd = test_path_buf("/home/user/sub-agent").abs(); - let session_workspace_roots = vec![session_cwd.clone(), profile_root]; + let session_runtime_workspace_roots = vec![session_cwd.clone()]; + let session_effective_workspace_roots = vec![session_cwd.clone(), profile_root]; let session_permission_profile = PermissionProfile::workspace_write() - .materialize_project_roots_with_workspace_roots(&session_workspace_roots); + .materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots); let configured = crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, @@ -335,6 +340,7 @@ async fn session_configured_preserves_profile_workspace_roots() { permission_profile: session_permission_profile.clone(), active_permission_profile: None, cwd: session_cwd.clone(), + runtime_workspace_roots: session_runtime_workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -347,7 +353,7 @@ async fn session_configured_preserves_profile_workspace_roots() { assert_eq!(&chat.config_ref().cwd, &session_cwd); assert_eq!( chat.config_ref().permissions.user_visible_workspace_roots(), - session_workspace_roots.as_slice() + session_runtime_workspace_roots.as_slice() ); assert_eq!( chat.config_ref().permissions.effective_permission_profile(), @@ -380,6 +386,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -420,6 +427,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -474,6 +482,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -744,6 +753,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -789,6 +799,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index df3615c0fd..4c66041271 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -584,6 +584,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure permission_profile: PermissionProfile::workspace_write(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -631,6 +632,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w permission_profile, active_permission_profile: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index e9fdb6d987..b97695e800 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1217,6 +1217,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -1403,6 +1404,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 53b60e0d8d..acd6b7111b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2230,6 +2230,7 @@ async fn session_configured_clears_goal_status_footer() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index f8c25c9670..8ea7af5d32 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -448,6 +448,7 @@ fn session_configured_event(model: &str) -> ThreadSessionState { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index e4d2dbab96..eee682fd79 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -42,9 +42,30 @@ pub(crate) struct ThreadSessionState { /// when the server knows it. pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, + pub(crate) runtime_workspace_roots: Vec, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option, } + +impl ThreadSessionState { + pub(crate) fn set_cwd_retargeting_implicit_runtime_workspace_root( + &mut self, + cwd: AbsolutePathBuf, + ) { + let previous_cwd = std::mem::replace(&mut self.cwd, cwd.clone()); + if !self.runtime_workspace_roots.contains(&previous_cwd) { + return; + } + + let previous_roots = std::mem::take(&mut self.runtime_workspace_roots); + self.runtime_workspace_roots.push(cwd); + for root in previous_roots { + if root != previous_cwd && !self.runtime_workspace_roots.contains(&root) { + self.runtime_workspace_roots.push(root); + } + } + } +} From 8adb6032cc931952ff7a329202b07932f070eadf Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 23:10:45 -0700 Subject: [PATCH 002/114] tui/exec: show effective workspace roots in summaries (#22612) ## Why This PR builds on [#22611](https://github.com/openai/codex/pull/22611). After `runtimeWorkspaceRoots` moved onto thread state, the user-facing summaries were still inconsistent about which roots they showed. In particular, `/status` and the exec startup summary could under-report extra workspace roots from `--add-dir` or from profile-defined `workspace_roots`, which made the new model look incorrect even when the permissions themselves were right. ## What Changed - switched the TUI status surfaces to summarize against `Config::effective_workspace_roots()` - updated the exec human-output summary to render from the effective permission profile instead of the raw constrained profile - added focused regressions for both the TUI and exec code paths so extra workspace roots stay visible in user-facing summaries ## Verification Targeted coverage for this follow-up lives in: - `codex-rs/tui/src/status/tests.rs` - `codex-rs/exec/src/event_processor_with_human_output_tests.rs` The added regressions verify that: - status output includes profile-defined workspace roots in the effective permissions summary - exec startup output includes runtime workspace roots instead of collapsing back to `cwd` only --- .../src/event_processor_with_human_output.rs | 5 +- ...event_processor_with_human_output_tests.rs | 71 +++++++++++++++++++ .../tui/src/chatwidget/status_surfaces.rs | 8 +-- codex-rs/tui/src/status/card.rs | 13 ++-- codex-rs/tui/src/status/tests.rs | 34 +++++++++ 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 755d754f08..c3190b8740 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -420,6 +420,7 @@ fn config_summary_entries( config: &Config, session_configured_event: &SessionConfiguredEvent, ) -> Vec<(&'static str, String)> { + let permission_profile = config.permissions.effective_permission_profile(); let mut entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", session_configured_event.model.clone()), @@ -434,9 +435,9 @@ fn config_summary_entries( ( "sandbox", summarize_permission_profile( - &config.permissions.effective_permission_profile(), + &permission_profile, &config.cwd, - config.permissions.user_visible_workspace_roots(), + config.effective_workspace_roots().as_slice(), ), ), ]; diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 17cfda4550..1e900f2b61 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -2,12 +2,17 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; +use codex_core::config::ConfigBuilder; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use codex_utils_sandbox_summary::summarize_permission_profile; @@ -15,6 +20,7 @@ use owo_colors::Style; use pretty_assertions::assert_eq; use super::EventProcessorWithHumanOutput; +use super::config_summary_entries; use super::final_message_from_turn_items; use super::reasoning_text; use super::should_print_final_message_to_stdout; @@ -168,6 +174,71 @@ fn summarizes_managed_read_only_permission_profile() { ); } +#[tokio::test] +async fn config_summary_entries_include_runtime_workspace_roots() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cwd = tempfile::tempdir().expect("create cwd"); + let extra_root = tempfile::tempdir().expect("create extra root"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + let cwd = cwd.path().to_path_buf().abs(); + let extra_root = extra_root.path().to_path_buf().abs(); + let expected_extra_root_name = extra_root + .file_name() + .expect("extra root should have file name") + .to_string_lossy() + .to_string(); + config.cwd = cwd.clone(); + config.workspace_roots = vec![cwd.clone(), extra_root]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + )) + .expect("set permission profile"); + + let session_configured_event = SessionConfiguredEvent { + session_id: SessionId::new(), + thread_id: ThreadId::new(), + forked_from_id: None, + thread_source: None, + thread_name: None, + model: "gpt-5.4".to_string(), + model_provider_id: config.model_provider_id.clone(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: config.approvals_reviewer, + permission_profile: config.permissions.effective_permission_profile(), + active_permission_profile: None, + cwd, + reasoning_effort: None, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + + let summary_entries = config_summary_entries(&config, &session_configured_event); + let sandbox_summary = summary_entries + .iter() + .find_map(|(key, value)| (*key == "sandbox").then_some(value)) + .expect("sandbox summary entry"); + assert!( + sandbox_summary.starts_with("workspace-write [workdir, ") + && sandbox_summary.contains(&expected_extra_root_name), + "expected runtime workspace root in sandbox summary: {summary_entries:?}" + ); +} + #[test] fn final_message_from_turn_items_uses_latest_agent_message() { let message = final_message_from_turn_items(&[ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 8dd5f327d3..e34fe4688a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -902,11 +902,9 @@ fn permissions_display(config: &Config) -> String { } let permission_profile = config.permissions.effective_permission_profile(); - let summary = summarize_permission_profile( - &permission_profile, - &config.cwd, - config.permissions.workspace_roots(), - ); + let workspace_roots = config.effective_workspace_roots(); + let summary = + summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots.as_slice()); if let Some(details) = summary.strip_prefix("read-only") && !details.contains("(network access enabled)") { diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 9a7c23606f..3d30f800b6 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -256,7 +256,7 @@ impl StatusHistoryCell { ) -> (Self, StatusHistoryHandle) { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); let permission_profile = config.permissions.effective_permission_profile(); - let workspace_roots = config.permissions.user_visible_workspace_roots(); + let workspace_roots = config.effective_workspace_roots(); let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", model_name.to_string()), @@ -267,7 +267,11 @@ impl StatusHistoryCell { ), ( "sandbox", - summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots), + summarize_permission_profile( + &permission_profile, + &config.cwd, + workspace_roots.as_slice(), + ), ), ]; if config.model_provider.wire_api == WireApi::Responses { @@ -291,8 +295,9 @@ impl StatusHistoryCell { .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); let active_permission_profile = config.permissions.active_permission_profile(); - let sandbox = status_permission_summary(&permission_profile, &config.cwd, workspace_roots); - let workspace_root_suffix = workspace_root_suffix(workspace_roots, &config.cwd); + let sandbox = + status_permission_summary(&permission_profile, &config.cwd, workspace_roots.as_slice()); + let workspace_root_suffix = workspace_root_suffix(workspace_roots.as_slice(), &config.cwd); let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); let permissions = status_permissions_label( active_permission_profile.as_ref(), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 65a390a007..9380718196 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -463,6 +463,40 @@ async fn status_permissions_workspace_roots_show_additional_directories() { ); } +#[tokio::test] +async fn status_permissions_workspace_roots_include_profile_defined_directories() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let profile_root = test_path_buf("/workspace/shared").abs(); + config + .permissions + .set_permission_profile_from_session_snapshot_with_profile_workspace_roots( + PermissionProfile::workspace_write_with( + std::slice::from_ref(&profile_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + Some(ActivePermissionProfile::new(":workspace")), + vec![profile_root.clone()], + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config), + Some(format!( + "Workspace [{}] (on-request)", + profile_root.display() + )) + ); +} + #[tokio::test] async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { let temp_home = TempDir::new().expect("temp home"); From 302149d9793173343f5bf3770ee51fe2f56fef73 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 15 May 2026 00:43:23 -0700 Subject: [PATCH 003/114] Fix signed macOS release promotion follow-up jobs (#22788) ## Why The `release_mode=promote_signed` path intentionally skips the build jobs after signed macOS artifacts are staged, then runs the `release` job from the signed handoff. In the `rust-v0.131.0-alpha.19` promotion run, `release` succeeded but the npm, PyPI, and `latest-alpha-cli` follow-up jobs were skipped because their custom job `if:` expressions let GitHub Actions apply the implicit `success()` status check before reading `needs.release.outputs.*`. The unsigned build handoff does not need DotSlash manifests. Publishing unsigned DotSlash manifests creates release assets that can conflict with the later signed promotion, especially shared outputs such as `bwrap`, `codex-command-runner`, and `codex-windows-sandbox-setup`. ## What Changed - Stop publishing DotSlash manifests when `SIGN_MACOS == 'false'`. - Delete `.github/dotslash-unsigned-config.json`. - Gate post-release jobs with the `!cancelled()` status function plus an explicit `needs.release.result == 'success'` check before consulting release outputs. - Keep the existing publish eligibility rules for npm, PyPI, WinGet, and `latest-alpha-cli`. ## Verification - `rg -n "dotslash-unsigned-config|SIGN_MACOS == 'false'.*dotslash|unsigned-config" .github/workflows/rust-release.yml .github || true` - `git diff --check -- .github/workflows/rust-release.yml .github/dotslash-unsigned-config.json` --- .github/dotslash-unsigned-config.json | 124 -------------------------- .github/workflows/rust-release.yml | 40 ++++++--- 2 files changed, 28 insertions(+), 136 deletions(-) delete mode 100644 .github/dotslash-unsigned-config.json diff --git a/.github/dotslash-unsigned-config.json b/.github/dotslash-unsigned-config.json deleted file mode 100644 index 65c44d5e8d..0000000000 --- a/.github/dotslash-unsigned-config.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "outputs": { - "codex-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex" - }, - "macos-x86_64": { - "regex": "^codex-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex" - }, - "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" - }, - "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" - }, - "windows-x86_64": { - "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" - }, - "windows-aarch64": { - "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" - } - } - }, - "codex-app-server-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-app-server-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex-app-server" - }, - "macos-x86_64": { - "regex": "^codex-app-server-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex-app-server" - }, - "linux-x86_64": { - "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" - }, - "linux-aarch64": { - "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" - }, - "windows-x86_64": { - "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" - }, - "windows-aarch64": { - "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" - } - } - }, - "codex-responses-api-proxy-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex-responses-api-proxy" - }, - "macos-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex-responses-api-proxy" - }, - "linux-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-responses-api-proxy" - }, - "linux-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-responses-api-proxy" - }, - "windows-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-responses-api-proxy.exe" - }, - "windows-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-responses-api-proxy.exe" - } - } - }, - "bwrap": { - "platforms": { - "linux-x86_64": { - "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", - "path": "bwrap" - }, - "linux-aarch64": { - "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", - "path": "bwrap" - } - } - }, - "codex-command-runner": { - "platforms": { - "windows-x86_64": { - "regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-command-runner.exe" - }, - "windows-aarch64": { - "regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-command-runner.exe" - } - } - }, - "codex-windows-sandbox-setup": { - "platforms": { - "windows-x86_64": { - "regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-windows-sandbox-setup.exe" - }, - "windows-aarch64": { - "regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-windows-sandbox-setup.exe" - } - } - } - } -} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2dc92cf684..0e13aa366c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1223,14 +1223,6 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'false' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-unsigned-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: @@ -1265,7 +1257,15 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} + # promote_signed intentionally skips build jobs that are ancestors of release; + # include the !cancelled() status function so Actions does not apply its implicit + # success() check to the whole dependency chain before evaluating release outputs. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_npm == 'true' + }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -1423,7 +1423,12 @@ jobs: # need release follow-up, but should not invalidate the Rust release itself. publish-python-runtime: # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_python_runtime == 'true' + }} name: publish-python-runtime needs: release runs-on: ubuntu-latest @@ -1464,7 +1469,13 @@ jobs: needs: release # Only publish stable/mainline releases to WinGet; pre-releases include a # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' && + !contains(needs.release.outputs.version, '-') + }} # This job only invokes a GitHub Action to open/update the winget-pkgs PR; # it does not execute Windows-only tooling, so Linux is sufficient. runs-on: ubuntu-latest @@ -1484,7 +1495,12 @@ jobs: update-branch: name: Update latest-alpha-cli branch - if: ${{ needs.release.outputs.sign_macos == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' + }} permissions: contents: write needs: release From a5e5faf2162600b6c69d1277628ede2b7f7e4571 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 11:35:42 +0200 Subject: [PATCH 004/114] Reject legacy [profiles] when using profile-v2 (#22647) ## Why `profile-v2` layers the selected profile file on top of the base user `config.toml`, but the legacy `[profiles]` table also stores named profile overrides in that same base file. Allowing both paths during one load makes it too easy to get a mixed profile where stale legacy settings still influence a profile-v2 run. ## What Changed - Detect a legacy `[profiles]` table in the base user config whenever `--profile-v2` selects a profile file. - Fail config loading with an `InvalidData` error that tells the user to move those settings into the selected profile-v2 file or remove `[profiles]`. - Add a loader regression covering `--profile-v2` with legacy `[profiles]` in `config.toml`. ## Testing - `cargo test -p codex-config profile_v2_rejects_legacy_profiles_in_base_user_config` --- codex-rs/config/src/loader/mod.rs | 42 +++++-- codex-rs/config/src/loader/tests.rs | 172 ++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 codex-rs/config/src/loader/tests.rs diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 37cb41b3e1..89a35afebe 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1,6 +1,8 @@ mod layer_io; #[cfg(target_os = "macos")] mod macos; +#[cfg(test)] +mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; @@ -211,19 +213,35 @@ pub async fn load_config_layers_state( // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to // contain overrides. - let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); - layers.push( - load_user_config_layer( - fs, - &base_user_file, - /*profile*/ None, - ignore_user_config, - strict_config, - ) - .await?, - ); - let active_user_file = overrides.user_config_path(codex_home)?; + let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); + let base_user_layer = load_user_config_layer( + fs, + &base_user_file, + /*profile*/ None, + ignore_user_config, + strict_config, + ) + .await?; + if let Some(active_user_profile) = active_user_profile.as_ref() + && base_user_layer.config.as_table().is_some_and(|config| { + config + .get("profiles") + .and_then(TomlValue::as_table) + .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "--profile-v2 `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`", + base_user_file.as_path().display(), + active_user_file.as_path().display() + ), + )); + } + layers.push(base_user_layer); + if active_user_file != base_user_file { layers.push( load_user_config_layer( diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs new file mode 100644 index 0000000000..2d9462bfa7 --- /dev/null +++ b/codex-rs/config/src/loader/tests.rs @@ -0,0 +1,172 @@ +use super::*; +use async_trait::async_trait; +use codex_file_system::CopyOptions; +use codex_file_system::CreateDirectoryOptions; +use codex_file_system::FileMetadata; +use codex_file_system::FileSystemResult; +use codex_file_system::FileSystemSandboxContext; +use codex_file_system::ReadDirectoryEntry; +use codex_file_system::RemoveOptions; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +struct TestFileSystem; + +#[async_trait] +impl ExecutorFileSystem for TestFileSystem { + async fn read_file( + &self, + path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + tokio::fs::read(path.as_path()).await + } + + async fn write_file( + &self, + _path: &AbsolutePathBuf, + _contents: Vec, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn create_directory( + &self, + _path: &AbsolutePathBuf, + _create_directory_options: CreateDirectoryOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn get_metadata( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + unimplemented!("test filesystem only supports reads") + } + + async fn read_directory( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + unimplemented!("test filesystem only supports reads") + } + + async fn remove( + &self, + _path: &AbsolutePathBuf, + _remove_options: RemoveOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn copy( + &self, + _source_path: &AbsolutePathBuf, + _destination_path: &AbsolutePathBuf, + _copy_options: CopyOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } +} + +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.work] +model = "gpt-work" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile in base user config"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile-v2 `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("config.toml"), + "unexpected error message: {message}" + ); + assert!( + message.contains("[profiles.work]"), + "unexpected error message: {message}" + ); +} + +#[tokio::test] +async fn profile_v2_allows_unrelated_legacy_profiles_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.dev] +model = "gpt-dev" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect("profile-v2 should allow unrelated legacy profiles in base user config"); +} From 0322ac3df89c945760e68db1d910119a584355be Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 11:40:35 +0200 Subject: [PATCH 005/114] [codex] Use compaction_trigger item for remote compaction v2 (#22809) ## Why Remote compaction v2 was still using `context_compaction` as both the request trigger and the compacted output shape. The Responses API now has the landed contract for this flow: Codex sends a dedicated `{ "type": "compaction_trigger" }` input item, and the backend returns the standard `compaction` output item with encrypted content. This aligns the v2 path with that wire contract while preserving the existing local compacted-history post-processing behavior. ## What changed - Add `ResponseItem::CompactionTrigger` and regenerate the app-server protocol schema fixtures. - Send `compaction_trigger` from `remote_compaction_v2` instead of a payload-less `context_compaction`. - Collect exactly one backend `compaction` output item, then reuse the existing compacted-history rebuilding path. - Treat the trigger item as a transient request marker rather than model output or persisted rollout/memory content. ## Verification - `cargo test -p codex-protocol compaction_trigger` - `cargo test -p codex-core remote_compact_v2` - `cargo test -p codex-core compact_remote_v2` - `cargo test -p codex-core responses_websocket_sends_response_processed_after_remote_compaction_v2` - `just write-app-server-schema` - `cargo test -p codex-app-server-protocol schema_fixtures` --- .../schema/json/ClientRequest.json | 16 +++++ .../codex_app_server_protocol.schemas.json | 16 +++++ .../codex_app_server_protocol.v2.schemas.json | 16 +++++ .../RawResponseItemCompletedNotification.json | 16 +++++ .../schema/json/v2/ThreadResumeParams.json | 16 +++++ .../schema/typescript/ResponseItem.ts | 2 +- codex-rs/core/src/agent/control.rs | 1 + codex-rs/core/src/arc_monitor.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 61 +++++++------------ codex-rs/core/src/context_manager/history.rs | 3 + codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/turn_timing.rs | 1 + .../core/tests/suite/client_websockets.rs | 2 +- codex-rs/core/tests/suite/compact_remote.rs | 25 ++++---- codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/models.rs | 23 +++++-- codex-rs/rollout/src/policy.rs | 2 + 18 files changed, 145 insertions(+), 59 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f40d16feb6..fffc610128 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2729,6 +2729,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { 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 e37c26881b..8b292f667d 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 @@ -14065,6 +14065,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { 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 653a1a5751..16e548e8c2 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 @@ -10614,6 +10614,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 6973d15baa..bd88872fb6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -732,6 +732,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 27674afc7b..e8a437f003 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -799,6 +799,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 6fa9beee25..e5e960ff81 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array bool { | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other, ) => false, diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index d1e679a631..a4f7e038e0 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -384,6 +384,7 @@ fn build_arc_monitor_message_item( | ResponseItem::ToolSearchOutput { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => None, } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index cc31d50b13..bc684647eb 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -302,6 +302,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 4dafb95f8e..9a7a7435f1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -165,9 +165,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::ContextCompaction { - encrypted_content: None, - }); + input.push(ResponseItem::CompactionTrigger); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -276,38 +274,25 @@ async fn run_remote_compaction_request_v2( Err(err) }) .await?; - collect_context_compaction_output(stream).await + collect_compaction_output(stream).await } -async fn collect_context_compaction_output( +async fn collect_compaction_output( mut stream: ResponseStream, ) -> CodexResult<(ResponseItem, String)> { let mut output_item_count = 0usize; - let mut context_compaction_count = 0usize; - let mut context_compaction_output = None; + let mut compaction_count = 0usize; + let mut compaction_output = None; let mut completed_response_id = None; while let Some(event) = stream.next().await { match event? { ResponseEvent::OutputItemDone(item) => { output_item_count += 1; - match item { - ResponseItem::ContextCompaction { - encrypted_content: Some(_), - } => { - context_compaction_count += 1; - if context_compaction_output.is_none() { - context_compaction_output = Some(item); - } + if let ResponseItem::Compaction { .. } = item { + compaction_count += 1; + if compaction_output.is_none() { + compaction_output = Some(item); } - ResponseItem::ContextCompaction { - encrypted_content: None, - } => { - return Err(CodexErr::Fatal( - "remote compaction v2 returned context_compaction without encrypted_content" - .to_string(), - )); - } - _ => {} } } ResponseEvent::Completed { response_id, .. } => { @@ -324,16 +309,16 @@ async fn collect_context_compaction_output( )); }; - if context_compaction_count != 1 { + if compaction_count != 1 { return Err(CodexErr::Fatal(format!( - "remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items" + "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items" ))); } - let Some(context_compaction_output) = context_compaction_output else { - unreachable!("context compaction output must exist when count is exactly one"); + let Some(compaction_output) = compaction_output else { + unreachable!("compaction output must exist when count is exactly one"); }; - Ok((context_compaction_output, response_id)) + Ok((compaction_output, response_id)) } fn build_v2_compacted_history( @@ -410,8 +395,8 @@ mod tests { encrypted_content: "old".to_string(), }, ]; - let output = ResponseItem::ContextCompaction { - encrypted_content: Some("new".to_string()), + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), }; let history = build_v2_compacted_history(&input, output.clone()); @@ -428,9 +413,9 @@ mod tests { } #[tokio::test] - async fn collect_context_compaction_output_accepts_additional_output_items() { - let context_compaction = ResponseItem::ContextCompaction { - encrypted_content: Some("encrypted".to_string()), + async fn collect_compaction_output_accepts_additional_output_items() { + let compaction = ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( @@ -438,7 +423,7 @@ mod tests { "IGNORED_COMPACT_REPLY", Some(MessagePhase::FinalAnswer), ))), - Ok(ResponseEvent::OutputItemDone(context_compaction.clone())), + Ok(ResponseEvent::OutputItemDone(compaction.clone())), Ok(ResponseEvent::Completed { response_id: "resp-compact".to_string(), token_usage: None, @@ -446,11 +431,11 @@ mod tests { }), ]); - let (output, response_id) = collect_context_compaction_output(stream) + let (output, response_id) = collect_compaction_output(stream) .await - .expect("context compaction should be collected"); + .expect("compaction should be collected"); - assert_eq!(output, context_compaction); + assert_eq!(output, compaction); assert_eq!(response_id, "resp-compact"); } } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 80c057e0eb..cfd9297382 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -400,6 +400,7 @@ impl ContextManager { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => item.clone(), } @@ -490,6 +491,7 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Other => false, } } @@ -688,6 +690,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 86710e4871..aee4bd360f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2002,6 +2002,7 @@ async fn try_run_sampling_request( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => false, }; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 3d35afae95..570e4a6e32 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -185,6 +185,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 030aecd29e..a14ee6518f 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -253,7 +253,7 @@ async fn responses_websocket_sends_response_processed_after_remote_compaction_v2 json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 8849d80773..720ae10599 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -697,7 +697,7 @@ async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> { +async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -721,7 +721,7 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( serde_json::json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), @@ -778,8 +778,8 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( assert_eq!(compact_request.path(), "/v1/responses"); let compact_body = compact_request.body_json().to_string(); assert!( - compact_body.contains("\"type\":\"context_compaction\""), - "expected v2 compaction request to include the context_compaction trigger item" + compact_body.contains("\"type\":\"compaction_trigger\""), + "expected v2 compaction request to include the compaction_trigger item" ); assert!( !compact_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), @@ -789,12 +789,12 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("\"type\":\"context_compaction\""), - "expected follow-up request to preserve the v2 context_compaction item" + follow_up_body.contains("\"type\":\"compaction\""), + "expected follow-up request to preserve the compaction item" ); assert!( follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), - "expected follow-up request to include the context compaction payload" + "expected follow-up request to include the compaction payload" ); assert!( follow_up_body.contains("hello remote compact"), @@ -805,8 +805,7 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compact_v2_accepts_additional_output_items_before_context_compaction() -> Result<()> -{ +async fn remote_compact_v2_accepts_additional_output_items_before_compaction() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -831,7 +830,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_context_compac serde_json::json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), @@ -878,12 +877,12 @@ async fn remote_compact_v2_accepts_additional_output_items_before_context_compac let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("\"type\":\"context_compaction\""), - "expected follow-up request to preserve the v2 context_compaction item" + follow_up_body.contains("\"type\":\"compaction\""), + "expected follow-up request to preserve the compaction item" ); assert!( follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), - "expected follow-up request to include the context compaction payload" + "expected follow-up request to include the compaction payload" ); assert!( !follow_up_body.contains("IGNORED_COMPACT_REPLY"), diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 49d8b8eb7e..6394c73ea2 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1141,6 +1141,7 @@ impl SessionTelemetry { ResponseItem::WebSearchCall { .. } => "web_search_call".into(), ResponseItem::ImageGenerationCall { .. } => "image_generation_call".into(), ResponseItem::Compaction { .. } => "compaction".into(), + ResponseItem::CompactionTrigger => "compaction_trigger".into(), ResponseItem::ContextCompaction { .. } => "context_compaction".into(), ResponseItem::Other => "other".into(), } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 512dbd5444..2706a47c82 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -887,7 +887,10 @@ pub enum ResponseItem { result: String, }, #[serde(alias = "compaction_summary")] - Compaction { encrypted_content: String }, + Compaction { + encrypted_content: String, + }, + CompactionTrigger, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -2407,20 +2410,28 @@ mod tests { } #[test] - fn serializes_context_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::ContextCompaction { - encrypted_content: None, - }; + fn serializes_compaction_trigger_without_payload() -> Result<()> { + let item = ResponseItem::CompactionTrigger; assert_eq!( serde_json::to_value(item)?, serde_json::json!({ - "type": "context_compaction", + "type": "compaction_trigger", }) ); Ok(()) } + #[test] + fn deserializes_compaction_trigger_without_payload() -> Result<()> { + let json = r#"{"type":"compaction_trigger"}"#; + + let item: ResponseItem = serde_json::from_str(json)?; + + assert_eq!(item, ResponseItem::CompactionTrigger); + Ok(()) + } + #[test] fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> { let json = r#"{ diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 9c42f8db61..ceb617763f 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -79,6 +79,7 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Other => false, } } @@ -99,6 +100,7 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => false, } From 6f1a01fbddc732bf839e415b29c451e27af90c96 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 11:47:54 +0200 Subject: [PATCH 006/114] Simplify tool executor and registry plumbing (#22636) ## Why The tool runtime path still had a typed output associated type on `ToolExecutor`, plus a core-only `RegisteredTool` adapter and extension-only executor aliases. That made every new shared tool runtime carry extra adapter plumbing before it could participate in core dispatch, extension tools, hook payloads, telemetry, and model-visible spec generation. This PR moves output erasure to the shared executor boundary so core and extension tools can use the same execution contract directly. ## What Changed - Changed `codex_tools::ToolExecutor` to return `Box` instead of an associated `Output` type. - Removed the extension-specific `ExtensionToolExecutor` / `ExtensionToolOutput` aliases and exposed `ToolExecutor` plus `ToolOutput` through `codex-extension-api`. - Reworked core tool registration around `CoreToolRuntime` and `ToolRegistry::from_tools`, removing the extra `RegisteredTool` / `ToolRegistryBuilder` layer. - Consolidated model-visible spec planning and registry construction in `core/src/tools/spec_plan.rs`, including deferred tool search and code-mode-only filtering. - Added `ToolOutput` helpers for post-tool-use hook ids and inputs so MCP, unified exec, extension, and other boxed outputs preserve the same hook payload behavior. - Updated core handlers, memories tools, and the related registry/spec/router tests to use the simplified contract. ## Test Coverage - Updated coverage for tool spec planning, registry lookup, deferred tool search registration, extension tool routing, post-tool-use hook payloads, dispatch tracing, guardian output extraction, and memories extension tool execution. --- .../core/src/session/tests/guardian_tests.rs | 24 +- .../src/tools/code_mode/execute_handler.rs | 19 +- .../core/src/tools/code_mode/wait_handler.rs | 14 +- codex-rs/core/src/tools/context.rs | 25 ++ .../agent_jobs/report_agent_job_result.rs | 14 +- .../agent_jobs/spawn_agents_on_csv.rs | 16 +- .../core/src/tools/handlers/apply_patch.rs | 19 +- codex-rs/core/src/tools/handlers/dynamic.rs | 17 +- .../src/tools/handlers/extension_tools.rs | 59 ++-- .../src/tools/handlers/goal/create_goal.rs | 15 +- .../core/src/tools/handlers/goal/get_goal.rs | 15 +- .../src/tools/handlers/goal/update_goal.rs | 15 +- codex-rs/core/src/tools/handlers/mcp.rs | 38 ++- .../list_mcp_resource_templates.rs | 15 +- .../mcp_resource/list_mcp_resources.rs | 15 +- .../mcp_resource/read_mcp_resource.rs | 15 +- .../core/src/tools/handlers/multi_agents.rs | 3 +- .../handlers/multi_agents/close_agent.rs | 11 +- .../handlers/multi_agents/resume_agent.rs | 11 +- .../tools/handlers/multi_agents/send_input.rs | 11 +- .../src/tools/handlers/multi_agents/spawn.rs | 11 +- .../src/tools/handlers/multi_agents/wait.rs | 11 +- .../src/tools/handlers/multi_agents_tests.rs | 33 +- .../src/tools/handlers/multi_agents_v2.rs | 3 +- .../handlers/multi_agents_v2/close_agent.rs | 11 +- .../handlers/multi_agents_v2/followup_task.rs | 11 +- .../handlers/multi_agents_v2/list_agents.rs | 11 +- .../handlers/multi_agents_v2/send_message.rs | 11 +- .../tools/handlers/multi_agents_v2/spawn.rs | 11 +- .../tools/handlers/multi_agents_v2/wait.rs | 11 +- codex-rs/core/src/tools/handlers/plan.rs | 14 +- .../src/tools/handlers/request_permissions.rs | 17 +- .../tools/handlers/request_plugin_install.rs | 17 +- .../src/tools/handlers/request_user_input.rs | 17 +- .../src/tools/handlers/shell/shell_command.rs | 17 +- .../core/src/tools/handlers/shell_tests.rs | 2 +- codex-rs/core/src/tools/handlers/test_sync.rs | 17 +- .../core/src/tools/handlers/tool_search.rs | 13 +- .../core/src/tools/handlers/unified_exec.rs | 13 +- .../handlers/unified_exec/exec_command.rs | 24 +- .../handlers/unified_exec/write_stdin.rs | 17 +- .../src/tools/handlers/unified_exec_tests.rs | 2 +- .../core/src/tools/handlers/view_image.rs | 16 +- codex-rs/core/src/tools/mod.rs | 1 - codex-rs/core/src/tools/registry.rs | 283 ++++++------------ codex-rs/core/src/tools/registry_tests.rs | 39 +-- codex-rs/core/src/tools/router.rs | 59 +--- codex-rs/core/src/tools/router_tests.rs | 11 +- codex-rs/core/src/tools/spec.rs | 77 ----- codex-rs/core/src/tools/spec_plan.rs | 261 +++++++++++----- codex-rs/core/src/tools/spec_plan_tests.rs | 71 +++-- codex-rs/core/src/tools/spec_plan_types.rs | 29 -- codex-rs/core/src/tools/spec_tests.rs | 220 +++----------- .../src/tools/tool_dispatch_trace_tests.rs | 16 +- .../ext/extension-api/src/contributors.rs | 7 +- .../extension-api/src/contributors/tools.rs | 15 - codex-rs/ext/extension-api/src/lib.rs | 3 +- codex-rs/ext/memories/src/extension.rs | 2 +- codex-rs/ext/memories/src/tests.rs | 13 +- codex-rs/ext/memories/src/tools/list.rs | 7 +- codex-rs/ext/memories/src/tools/mod.rs | 4 +- codex-rs/ext/memories/src/tools/read.rs | 7 +- codex-rs/ext/memories/src/tools/search.rs | 7 +- codex-rs/tools/src/tool_executor.rs | 7 +- codex-rs/tools/src/tool_output.rs | 18 ++ 65 files changed, 830 insertions(+), 1008 deletions(-) delete mode 100644 codex-rs/core/src/tools/spec_plan_types.rs delete mode 100644 codex-rs/ext/extension-api/src/contributors/tools.rs diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 718b9bf05f..5db4aab985 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -5,8 +5,9 @@ use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::sandboxing::SandboxPermissions; use crate::test_support::models_manager_with_provider; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; @@ -22,8 +23,8 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::AskForApproval; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -48,8 +49,23 @@ use tempfile::tempdir; use tokio::time::timeout; use tokio_util::sync::CancellationToken; -fn expect_text_output(output: &FunctionToolOutput) -> String { - function_call_output_content_items_to_text(&output.body).unwrap_or_default() +fn expect_text_output(output: &T) -> String +where + T: ToolOutput + ?Sized, +{ + let response = output.to_response_item( + "call-guardian", + &ToolPayload::Function { + arguments: "{}".to_string(), + }, + ); + match response { + ResponseInputItem::FunctionCallOutput { output, .. } + | ResponseInputItem::CustomToolCallOutput { output, .. } => { + output.body.to_text().unwrap_or_default() + } + other => panic!("expected function output, got {other:?}"), + } } #[tokio::test] diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 26ef021ad2..e101e1a800 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -2,8 +2,9 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -89,8 +90,6 @@ impl CodeModeExecuteHandler { #[async_trait::async_trait] impl ToolExecutor for CodeModeExecuteHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(PUBLIC_TOOL_NAME) } @@ -99,7 +98,10 @@ impl ToolExecutor for CodeModeExecuteHandler { Some(self.spec.clone()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -110,9 +112,10 @@ impl ToolExecutor for CodeModeExecuteHandler { } = invocation; match payload { - ToolPayload::Custom { input } if is_exec_tool_name(&tool_name) => { - self.execute(session, turn, call_id, input).await - } + ToolPayload::Custom { input } if is_exec_tool_name(&tool_name) => self + .execute(session, turn, call_id, input) + .await + .map(boxed_tool_output), _ => Err(FunctionCallError::RespondToModel(format!( "{PUBLIC_TOOL_NAME} expects raw JavaScript source text" ))), @@ -120,7 +123,7 @@ impl ToolExecutor for CodeModeExecuteHandler { } } -impl ToolHandler for CodeModeExecuteHandler { +impl CoreToolRuntime for CodeModeExecuteHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Custom { .. }) } diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 4f828c8910..725535339f 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -1,11 +1,11 @@ use serde::Deserialize; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -43,8 +43,6 @@ where #[async_trait::async_trait] impl ToolExecutor for CodeModeWaitHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(WAIT_TOOL_NAME) } @@ -53,7 +51,10 @@ impl ToolExecutor for CodeModeWaitHandler { Some(create_wait_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -99,6 +100,7 @@ impl ToolExecutor for CodeModeWaitHandler { } handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at) .await + .map(boxed_tool_output) .map_err(FunctionCallError::RespondToModel) } _ => Err(FunctionCallError::RespondToModel(format!( @@ -108,4 +110,4 @@ impl ToolExecutor for CodeModeWaitHandler { } } -impl ToolHandler for CodeModeWaitHandler {} +impl CoreToolRuntime for CodeModeWaitHandler {} diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 0f3ae631a0..5fc19a2a2e 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -28,6 +28,13 @@ use tokio_util::sync::CancellationToken; pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; +pub(crate) fn boxed_tool_output(output: T) -> Box +where + T: ToolOutput + 'static, +{ + Box::new(output) +} + pub type SharedTurnDiffTracker = Arc>; #[derive(Clone, Debug, Eq, PartialEq)] @@ -91,6 +98,10 @@ impl ToolOutput for McpToolOutput { }) } + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + Some(self.tool_input.clone()) + } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { serde_json::to_value(&self.result).ok() } @@ -327,6 +338,20 @@ impl ToolOutput for ExecCommandToolOutput { ) } + fn post_tool_use_id(&self, call_id: &str) -> String { + if self.event_call_id.is_empty() { + call_id.to_string() + } else { + self.event_call_id.clone() + } + } + + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + self.hook_command + .as_ref() + .map(|command| serde_json::json!({ "command": command })) + } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { if self.process_id.is_some() || self.hook_command.is_none() { return None; diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs index e08eb4b5fb..4641e793c8 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::agent_jobs_spec::create_report_agent_job_result_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -14,8 +15,6 @@ pub struct ReportAgentJobResultHandler; #[async_trait::async_trait] impl ToolExecutor for ReportAgentJobResultHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("report_agent_job_result") } @@ -24,7 +23,10 @@ impl ToolExecutor for ReportAgentJobResultHandler { Some(create_report_agent_job_result_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, payload, .. } = invocation; @@ -38,11 +40,11 @@ impl ToolExecutor for ReportAgentJobResultHandler { } }; - handle(session, arguments).await + handle(session, arguments).await.map(boxed_tool_output) } } -impl ToolHandler for ReportAgentJobResultHandler { +impl CoreToolRuntime for ReportAgentJobResultHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index a384f2d8cc..0e51390e79 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::agent_jobs_spec::create_spawn_agents_on_csv_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_absolute_path::AbsolutePathBuf; @@ -15,8 +16,6 @@ pub struct SpawnAgentsOnCsvHandler; #[async_trait::async_trait] impl ToolExecutor for SpawnAgentsOnCsvHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agents_on_csv") } @@ -25,7 +24,10 @@ impl ToolExecutor for SpawnAgentsOnCsvHandler { Some(create_spawn_agents_on_csv_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -42,11 +44,13 @@ impl ToolExecutor for SpawnAgentsOnCsvHandler { } }; - handle(session, turn, arguments).await + handle(session, turn, arguments) + .await + .map(boxed_tool_output) } } -impl ToolHandler for SpawnAgentsOnCsvHandler { +impl CoreToolRuntime for SpawnAgentsOnCsvHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c8e2461e03..500751137b 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -17,8 +17,8 @@ use crate::tools::context::ApplyPatchToolOutput; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::handlers::apply_granted_turn_permissions; @@ -27,11 +27,11 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::runtimes::apply_patch::ApplyPatchRequest; use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; use crate::tools::sandboxing::ToolCtx; @@ -299,8 +299,6 @@ async fn effective_patch_permissions( #[async_trait::async_trait] impl ToolExecutor for ApplyPatchHandler { - type Output = ApplyPatchToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("apply_patch") } @@ -309,7 +307,10 @@ impl ToolExecutor for ApplyPatchHandler { Some(create_apply_patch_freeform_tool(self.multi_environment)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -359,7 +360,7 @@ impl ToolExecutor for ApplyPatchHandler { { InternalApplyPatchInvocation::Output(item) => { let content = item?; - Ok(ApplyPatchToolOutput::from_text(content)) + Ok(boxed_tool_output(ApplyPatchToolOutput::from_text(content))) } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); @@ -414,7 +415,7 @@ impl ToolExecutor for ApplyPatchHandler { Some(&tracker), ); let content = emitter.finish(event_ctx, out, delta.as_ref()).await?; - Ok(ApplyPatchToolOutput::from_text(content)) + Ok(boxed_tool_output(ApplyPatchToolOutput::from_text(content))) } } } @@ -438,7 +439,7 @@ impl ToolExecutor for ApplyPatchHandler { } } -impl ToolHandler for ApplyPatchHandler { +impl CoreToolRuntime for ApplyPatchHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Custom { .. }) } @@ -472,7 +473,7 @@ impl ToolHandler for ApplyPatchHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 29dc5199e3..5d20128ff3 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -4,10 +4,11 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExposure; -use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::dynamic_tools::DynamicToolCallRequest; @@ -62,8 +63,6 @@ impl DynamicToolHandler { #[async_trait::async_trait] impl ToolExecutor for DynamicToolHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { self.tool_name.clone() } @@ -76,7 +75,10 @@ impl ToolExecutor for DynamicToolHandler { self.exposure } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -117,11 +119,14 @@ impl ToolExecutor for DynamicToolHandler { .into_iter() .map(FunctionCallOutputContentItem::from) .collect::>(); - Ok(FunctionToolOutput::from_content(body, Some(success))) + Ok(boxed_tool_output(FunctionToolOutput::from_content( + body, + Some(success), + ))) } } -impl ToolHandler for DynamicToolHandler { +impl CoreToolRuntime for DynamicToolHandler { fn search_info(&self) -> Option { ToolSearchInfo::from_spec( self.search_text.clone(), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index c4018fe936..37ad2d65fd 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use codex_extension_api::ExtensionToolExecutor; -use codex_extension_api::ExtensionToolOutput; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -13,18 +11,16 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -pub(crate) struct ExtensionToolHandler { - executor: Arc, -} +pub(crate) struct ExtensionToolAdapter(Arc>); -impl ExtensionToolHandler { - pub(crate) fn new(executor: Arc) -> Self { - Self { executor } +impl ExtensionToolAdapter { + pub(crate) fn new(executor: Arc>) -> Self { + Self(executor) } fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> { @@ -36,23 +32,32 @@ impl ExtensionToolHandler { } #[async_trait::async_trait] -impl ToolExecutor for ExtensionToolHandler { - type Output = ExtensionToolOutput; - +impl ToolExecutor for ExtensionToolAdapter { fn tool_name(&self) -> ToolName { - self.executor.tool_name() + self.0.tool_name() } fn spec(&self) -> Option { - self.executor.spec() + self.0.spec() } - async fn handle(&self, invocation: ToolInvocation) -> Result { - self.executor.handle(to_extension_call(&invocation)).await + fn exposure(&self) -> crate::tools::registry::ToolExposure { + self.0.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.0.supports_parallel_tool_calls() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + self.0.handle(to_extension_call(&invocation)).await } } -impl ToolHandler for ExtensionToolHandler { +impl CoreToolRuntime for ExtensionToolAdapter { fn matches_kind(&self, payload: &ToolPayload) -> bool { self.arguments_from_payload(payload).is_some() } @@ -68,7 +73,7 @@ impl ToolHandler for ExtensionToolHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn ToolOutput, ) -> Option { let arguments = self.arguments_from_payload(&invocation.payload)?; Some(PostToolUsePayload { @@ -104,22 +109,20 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; - use super::ExtensionToolHandler; + use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; + use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; - use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; struct StubExtensionExecutor; #[async_trait::async_trait] impl codex_extension_api::ToolExecutor for StubExtensionExecutor { - type Output = codex_tools::JsonToolOutput; - fn tool_name(&self) -> codex_tools::ToolName { codex_tools::ToolName::plain("extension_echo") } @@ -148,14 +151,16 @@ mod tests { async fn handle( &self, _call: codex_tools::ToolCall, - ) -> Result { - Ok(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) + ) -> Result, codex_tools::FunctionCallError> { + Ok(Box::new(codex_tools::JsonToolOutput::new( + json!({ "ok": true }), + ))) } } #[tokio::test] async fn exposes_generic_hook_payloads() { - let handler = ExtensionToolHandler::new(Arc::new(StubExtensionExecutor)); + let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor)); let (session, turn) = crate::session::tests::make_session_and_context().await; let invocation = ToolInvocation { session: session.into(), @@ -172,14 +177,14 @@ mod tests { let output = codex_tools::JsonToolOutput::new(json!({ "ok": true })); assert_eq!( - ToolHandler::pre_tool_use_payload(&handler, &invocation), + CoreToolRuntime::pre_tool_use_payload(&handler, &invocation), Some(PreToolUsePayload { tool_name: HookToolName::new("extension_echo"), tool_input: json!({ "message": "hello" }), }) ); assert_eq!( - ToolHandler::post_tool_use_payload(&handler, &invocation, &output), + CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output), Some(PostToolUsePayload { tool_name: HookToolName::new("extension_echo"), tool_use_id: "call-extension".to_string(), diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs index f7575e1e52..9ae0107f34 100644 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/create_goal.rs @@ -1,13 +1,13 @@ use crate::function_tool::FunctionCallError; use crate::goals::CreateGoalRequest; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_create_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -20,8 +20,6 @@ pub struct CreateGoalHandler; #[async_trait::async_trait] impl ToolExecutor for CreateGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(CREATE_GOAL_TOOL_NAME) } @@ -30,7 +28,10 @@ impl ToolExecutor for CreateGoalHandler { Some(create_create_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -70,8 +71,8 @@ impl ToolExecutor for CreateGoalHandler { FunctionCallError::RespondToModel(format_goal_error(err)) } })?; - goal_response(Some(goal), CompletionBudgetReport::Omit) + goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output) } } -impl ToolHandler for CreateGoalHandler {} +impl CoreToolRuntime for CreateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs index 20691b8b5b..ff40227fab 100644 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/get_goal.rs @@ -1,11 +1,11 @@ use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_get_goal_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -17,8 +17,6 @@ pub struct GetGoalHandler; #[async_trait::async_trait] impl ToolExecutor for GetGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(GET_GOAL_TOOL_NAME) } @@ -27,7 +25,10 @@ impl ToolExecutor for GetGoalHandler { Some(create_get_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, payload, .. } = invocation; @@ -38,7 +39,7 @@ impl ToolExecutor for GetGoalHandler { .get_thread_goal() .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit) + goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output) } _ => Err(FunctionCallError::RespondToModel( "get_goal handler received unsupported payload".to_string(), @@ -47,4 +48,4 @@ impl ToolExecutor for GetGoalHandler { } } -impl ToolHandler for GetGoalHandler {} +impl CoreToolRuntime for GetGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index bb8ed10efd..8cf8ce546a 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -1,14 +1,14 @@ use crate::function_tool::FunctionCallError; use crate::goals::GoalRuntimeEvent; use crate::goals::SetGoalRequest; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_update_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::protocol::ThreadGoalStatus; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -22,8 +22,6 @@ pub struct UpdateGoalHandler; #[async_trait::async_trait] impl ToolExecutor for UpdateGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(UPDATE_GOAL_TOOL_NAME) } @@ -32,7 +30,10 @@ impl ToolExecutor for UpdateGoalHandler { Some(create_update_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -73,8 +74,8 @@ impl ToolExecutor for UpdateGoalHandler { ) .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include) + goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output) } } -impl ToolHandler for UpdateGoalHandler {} +impl CoreToolRuntime for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index f82f741346..6f4f79505b 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -6,15 +6,15 @@ use crate::mcp_tool_call::handle_mcp_tool_call; use crate::original_image_detail::can_request_original_image_detail; use crate::tools::context::McpToolOutput; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExposure; -use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolTelemetryTags; use crate::tools::tool_search_entry::ToolSearchInfo; use codex_mcp::ToolInfo; @@ -47,8 +47,6 @@ impl McpHandler { #[async_trait::async_trait] impl ToolExecutor for McpHandler { - type Output = McpToolOutput; - fn tool_name(&self) -> ToolName { self.tool_info.canonical_tool_name() } @@ -89,7 +87,10 @@ impl ToolExecutor for McpHandler { self.tool_info.supports_parallel_tool_calls } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -119,17 +120,17 @@ impl ToolExecutor for McpHandler { ) .await; - Ok(McpToolOutput { + Ok(boxed_tool_output(McpToolOutput { result: result.result, tool_input: result.tool_input, wall_time: started.elapsed(), original_image_detail_supported: can_request_original_image_detail(&turn.model_info), truncation_policy: turn.truncation_policy, - }) + })) } } -impl ToolHandler for McpHandler { +impl CoreToolRuntime for McpHandler { fn search_info(&self) -> Option { let source_name = self .tool_info @@ -156,12 +157,17 @@ impl ToolHandler for McpHandler { ) } - async fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags { - let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; - if let Some(origin) = &self.tool_info.server_origin { - tags.push(("mcp_server_origin", origin.clone())); - } - tags + fn telemetry_tags<'a>( + &'a self, + _invocation: &'a ToolInvocation, + ) -> futures::future::BoxFuture<'a, ToolTelemetryTags> { + Box::pin(async { + let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; + if let Some(origin) = &self.tool_info.server_origin { + tags.push(("mcp_server_origin", origin.clone())); + } + tags + }) } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { @@ -200,7 +206,7 @@ impl ToolHandler for McpHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let ToolPayload::Function { .. } = &invocation.payload else { return None; @@ -211,7 +217,7 @@ impl ToolHandler for McpHandler { Some(PostToolUsePayload { tool_name: HookToolName::new(self.tool_name().to_string()), tool_use_id: invocation.call_id.clone(), - tool_input: result.tool_input.clone(), + tool_input: result.post_tool_use_input(&invocation.payload)?, tool_response, }) } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 71749e9cd1..866c7a9e12 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resource_templates_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ListMcpResourceTemplatesHandler; #[async_trait::async_trait] impl ToolExecutor for ListMcpResourceTemplatesHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("list_mcp_resource_templates") } @@ -46,7 +44,10 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { clippy::await_holding_invalid_type, reason = "MCP resource template listing reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -131,7 +132,7 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -166,4 +167,4 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { } } -impl ToolHandler for ListMcpResourceTemplatesHandler {} +impl CoreToolRuntime for ListMcpResourceTemplatesHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index 08b387376b..c87747eea4 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resources_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ListMcpResourcesHandler; #[async_trait::async_trait] impl ToolExecutor for ListMcpResourcesHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("list_mcp_resources") } @@ -46,7 +44,10 @@ impl ToolExecutor for ListMcpResourcesHandler { clippy::await_holding_invalid_type, reason = "MCP resource listing reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -129,7 +130,7 @@ impl ToolExecutor for ListMcpResourcesHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -164,4 +165,4 @@ impl ToolExecutor for ListMcpResourcesHandler { } } -impl ToolHandler for ListMcpResourcesHandler {} +impl CoreToolRuntime for ListMcpResourcesHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index bd8172ac74..3bb4d7f0ec 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_read_mcp_resource_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ReadMcpResourceHandler; #[async_trait::async_trait] impl ToolExecutor for ReadMcpResourceHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("read_mcp_resource") } @@ -42,7 +40,10 @@ impl ToolExecutor for ReadMcpResourceHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -112,7 +113,7 @@ impl ToolExecutor for ReadMcpResourceHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -147,4 +148,4 @@ impl ToolExecutor for ReadMcpResourceHandler { } } -impl ToolHandler for ReadMcpResourceHandler {} +impl CoreToolRuntime for ReadMcpResourceHandler {} diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index d0c5edfb77..1587c4e840 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -13,10 +13,11 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 6459a899fe..805f569b8e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = CloseAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("close_agent") } @@ -17,8 +15,11 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v1()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_close_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_close_agent(invocation).await.map(boxed_tool_output) } } @@ -105,7 +106,7 @@ async fn handle_close_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 9dab2d999d..71b29fb423 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -9,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = ResumeAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("resume_agent") } @@ -19,8 +17,11 @@ impl ToolExecutor for Handler { Some(create_resume_agent_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_resume_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_resume_agent(invocation).await.map(boxed_tool_output) } } @@ -133,7 +134,7 @@ async fn handle_resume_agent( Ok(ResumeAgentResult { status }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index a6067e5b12..74f7eb05b4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -8,8 +8,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SendInputResult; - fn tool_name(&self) -> ToolName { ToolName::plain("send_input") } @@ -18,7 +16,10 @@ impl ToolExecutor for Handler { Some(create_send_input_tool_v1()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -85,11 +86,11 @@ impl ToolExecutor for Handler { .await; let submission_id = result?; - Ok(SendInputResult { submission_id }) + Ok(boxed_tool_output(SendInputResult { submission_id })) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 91f5990210..d19849d443 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -24,8 +24,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SpawnAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agent") } @@ -34,8 +32,11 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v1(self.options.clone())) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_spawn_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_spawn_agent(invocation).await.map(boxed_tool_output) } } @@ -198,7 +199,7 @@ async fn handle_spawn_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index d325edeeb0..a69784b901 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -29,8 +29,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = WaitAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("wait_agent") } @@ -39,7 +37,10 @@ impl ToolExecutor for Handler { Some(create_wait_agent_tool_v1(self.options)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -197,11 +198,11 @@ impl ToolExecutor for Handler { ) .await; - Ok(result) + Ok(boxed_tool_output(result)) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 c6a0007f13..0044bf8522 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -317,7 +317,8 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() { })), )) .await - .expect_err("fork_context should reject agent_type overrides"); + .err() + .expect("fork_context should reject agent_type overrides"); assert_eq!( err, @@ -351,7 +352,8 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() { })), )) .await - .expect_err("forked spawn should reject child model overrides"); + .err() + .expect("forked spawn should reject child model overrides"); assert_eq!( err, @@ -395,7 +397,8 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { })), )) .await - .expect_err("fork_turns=all should reject agent_type overrides"); + .err() + .expect("fork_turns=all should reject agent_type overrides"); assert_eq!( err, @@ -435,7 +438,8 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over })), )) .await - .expect_err("default full fork should reject child model overrides"); + .err() + .expect("default full fork should reject child model overrides"); assert_eq!( err, @@ -505,7 +509,8 @@ async fn spawn_agent_service_tier_override_validates_the_effective_child_model() })), )) .await - .expect_err("unknown service tier should be rejected"); + .err() + .expect("unknown service tier should be rejected"); assert_eq!( err, @@ -530,7 +535,8 @@ async fn spawn_agent_service_tier_override_validates_the_effective_child_model() })), )) .await - .expect_err("tier unsupported by the final child model should be rejected"); + .err() + .expect("tier unsupported by the final child model should be rejected"); assert_eq!( err, @@ -1116,7 +1122,8 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { })), )) .await - .expect_err("legacy fork_context should be rejected"); + .err() + .expect("legacy fork_context should be rejected"); assert_eq!( err, @@ -1155,7 +1162,8 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { })), )) .await - .expect_err("invalid fork_turns should be rejected"); + .err() + .expect("invalid fork_turns should be rejected"); assert_eq!( err, @@ -1194,7 +1202,8 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { })), )) .await - .expect_err("zero turn count should be rejected"); + .err() + .expect("zero turn count should be rejected"); assert_eq!( err, @@ -3630,7 +3639,8 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { function_payload(json!({"target": "/root"})), )) .await - .expect_err("close_agent should reject the root path"); + .err() + .expect("close_agent should reject the root path"); assert_eq!( root_path_error, FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) @@ -3644,7 +3654,8 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { function_payload(json!({"target": root.thread_id.to_string()})), )) .await - .expect_err("close_agent should reject the root thread id"); + .err() + .expect("close_agent should reject the root thread id"); assert_eq!( root_id_error, FunctionCallError::RespondToModel("root is not a spawned agent".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 0190fe04ac..068f393379 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -6,10 +6,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::AgentPath; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 7b575f8258..180cf1a9d8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = CloseAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("close_agent") } @@ -17,8 +15,11 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v2()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_close_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_close_agent(invocation).await.map(boxed_tool_output) } } @@ -117,7 +118,7 @@ async fn handle_close_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index 7d111f0418..4b3edcd487 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -2,7 +2,6 @@ use super::message_tool::FollowupTaskArgs; use super::message_tool::MessageDeliveryMode; use super::message_tool::handle_message_string_tool; use super::*; -use crate::tools::context::FunctionToolOutput; use crate::tools::handlers::multi_agents_spec::create_followup_task_tool; use codex_tools::ToolSpec; @@ -10,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("followup_task") } @@ -20,7 +17,10 @@ impl ToolExecutor for Handler { Some(create_followup_task_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: FollowupTaskArgs = parse_arguments(&arguments)?; handle_message_string_tool( @@ -30,10 +30,11 @@ impl ToolExecutor for Handler { args.message, ) .await + .map(boxed_tool_output) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 8b0ee551e3..b8b9072da9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = ListAgentsResult; - fn tool_name(&self) -> ToolName { ToolName::plain("list_agents") } @@ -17,7 +15,10 @@ impl ToolExecutor for Handler { Some(create_list_agents_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -37,11 +38,11 @@ impl ToolExecutor for Handler { .await .map_err(collab_spawn_error)?; - Ok(ListAgentsResult { agents }) + Ok(boxed_tool_output(ListAgentsResult { agents })) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index 584feec61f..202e28e0c9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -2,7 +2,6 @@ use super::message_tool::MessageDeliveryMode; use super::message_tool::SendMessageArgs; use super::message_tool::handle_message_string_tool; use super::*; -use crate::tools::context::FunctionToolOutput; use crate::tools::handlers::multi_agents_spec::create_send_message_tool; use codex_tools::ToolSpec; @@ -10,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("send_message") } @@ -20,7 +17,10 @@ impl ToolExecutor for Handler { Some(create_send_message_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: SendMessageArgs = parse_arguments(&arguments)?; handle_message_string_tool( @@ -30,10 +30,11 @@ impl ToolExecutor for Handler { args.message, ) .await + .map(boxed_tool_output) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 3ad7b87145..2daa1f07db 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 @@ -26,8 +26,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SpawnAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agent") } @@ -36,8 +34,11 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v2(self.options.clone())) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_spawn_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_spawn_agent(invocation).await.map(boxed_tool_output) } } @@ -229,7 +230,7 @@ async fn handle_spawn_agent( } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 53c976c9d4..8913411b53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -21,8 +21,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = WaitAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("wait_agent") } @@ -31,7 +29,10 @@ impl ToolExecutor for Handler { Some(create_wait_agent_tool_v2(self.options)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -97,11 +98,11 @@ impl ToolExecutor for Handler { ) .await; - Ok(result) + Ok(boxed_tool_output(result)) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 9868c77ca7..f5c94826f7 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::plan_spec::create_update_plan_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; @@ -46,8 +47,6 @@ impl ToolOutput for PlanToolOutput { #[async_trait::async_trait] impl ToolExecutor for PlanHandler { - type Output = PlanToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("update_plan") } @@ -56,7 +55,10 @@ impl ToolExecutor for PlanHandler { Some(create_update_plan_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -85,11 +87,11 @@ impl ToolExecutor for PlanHandler { .send_event(turn.as_ref(), EventMsg::PlanUpdate(args)) .await; - Ok(PlanToolOutput) + Ok(boxed_tool_output(PlanToolOutput)) } } -impl ToolHandler for PlanHandler {} +impl CoreToolRuntime for PlanHandler {} fn parse_update_plan_arguments(arguments: &str) -> Result { serde_json::from_str::(arguments).map_err(|e| { diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 2f2e71677e..007c8d2208 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -5,11 +5,12 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -17,8 +18,6 @@ pub struct RequestPermissionsHandler; #[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("request_permissions") } @@ -29,7 +28,10 @@ impl ToolExecutor for RequestPermissionsHandler { )) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -75,8 +77,11 @@ impl ToolExecutor for RequestPermissionsHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestPermissionsHandler {} +impl CoreToolRuntime for RequestPermissionsHandler {} diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 6073831e02..3b53fd007a 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -32,10 +32,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; #[derive(Default)] pub struct RequestPluginInstallHandler { @@ -52,8 +53,6 @@ impl RequestPluginInstallHandler { #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) } @@ -70,7 +69,10 @@ impl ToolExecutor for RequestPluginInstallHandler { clippy::await_holding_invalid_type, reason = "plugin install discovery reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { payload, session, @@ -190,11 +192,14 @@ impl ToolExecutor for RequestPluginInstallHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestPluginInstallHandler {} +impl CoreToolRuntime for RequestPluginInstallHandler {} async fn maybe_persist_disabled_install_request( session: &crate::session::session::Session, diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 7597cb6004..8384e02637 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -2,14 +2,15 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME; use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool; use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_args; use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description; use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::ToolName; @@ -21,8 +22,6 @@ pub struct RequestUserInputHandler { #[async_trait::async_trait] impl ToolExecutor for RequestUserInputHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME) } @@ -33,7 +32,10 @@ impl ToolExecutor for RequestUserInputHandler { )) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -80,11 +82,14 @@ impl ToolExecutor for RequestUserInputHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestUserInputHandler {} +impl CoreToolRuntime for RequestUserInputHandler {} #[cfg(test)] #[path = "request_user_input_tests.rs"] diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 54d74f13b7..53156c5cfa 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -10,19 +10,18 @@ use crate::function_tool::FunctionCallError; use crate::maybe_emit_implicit_skill_invocation; use crate::session::turn_context::TurnContext; use crate::shell::Shell; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::resolve_workdir_base_path; use crate::tools::handlers::rewrite_function_string_argument; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::runtimes::shell::ShellRuntimeBackend; use codex_tools::ToolSpec; @@ -129,8 +128,6 @@ impl From for ShellCommandHandler { #[async_trait::async_trait] impl ToolExecutor for ShellCommandHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("shell_command") } @@ -148,7 +145,10 @@ impl ToolExecutor for ShellCommandHandler { self.options.is_some() } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -201,10 +201,11 @@ impl ToolExecutor for ShellCommandHandler { shell_runtime_backend: self.shell_runtime_backend(), }) .await + .map(boxed_tool_output) } } -impl ToolHandler for ShellCommandHandler { +impl CoreToolRuntime for ShellCommandHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -240,7 +241,7 @@ impl ToolHandler for ShellCommandHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 660bac6789..eda23533a3 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -18,7 +18,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::ShellCommandHandler; use crate::tools::hook_names::HookToolName; -use crate::tools::registry::ToolHandler; +use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use codex_shell_command::is_safe_command::is_known_safe_command; use codex_shell_command::powershell::try_find_powershell_executable_blocking; diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index feee6470a3..d3c95dea7e 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -12,10 +12,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::test_sync_spec::create_test_sync_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -58,8 +59,6 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { #[async_trait::async_trait] impl ToolExecutor for TestSyncHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("test_sync_tool") } @@ -72,7 +71,10 @@ impl ToolExecutor for TestSyncHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { payload, .. } = invocation; let arguments = match payload { @@ -102,11 +104,14 @@ impl ToolExecutor for TestSyncHandler { sleep(Duration::from_millis(delay)).await; } - Ok(FunctionToolOutput::from_text("ok".to_string(), Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) } } -impl ToolHandler for TestSyncHandler {} +impl CoreToolRuntime for TestSyncHandler {} async fn wait_on_barrier(args: BarrierArgs) -> Result<(), FunctionCallError> { if args.participants == 0 { diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 579826ac04..ffb25cb880 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::tool_search_spec::create_tool_search_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchEntry; use crate::tools::tool_search_entry::ToolSearchInfo; use bm25::Document; @@ -54,8 +55,6 @@ impl ToolSearchHandler { #[async_trait::async_trait] impl ToolExecutor for ToolSearchHandler { - type Output = ToolSearchOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(TOOL_SEARCH_TOOL_NAME) } @@ -74,7 +73,7 @@ impl ToolExecutor for ToolSearchHandler { async fn handle( &self, invocation: ToolInvocation, - ) -> Result { + ) -> Result, FunctionCallError> { let ToolInvocation { payload, .. } = invocation; let args = match payload { @@ -101,16 +100,16 @@ impl ToolExecutor for ToolSearchHandler { } if self.entries.is_empty() { - return Ok(ToolSearchOutput { tools: Vec::new() }); + return Ok(boxed_tool_output(ToolSearchOutput { tools: Vec::new() })); } let tools = self.search(query, limit)?; - Ok(ToolSearchOutput { tools }) + Ok(boxed_tool_output(ToolSearchOutput { tools })) } } -impl ToolHandler for ToolSearchHandler {} +impl CoreToolRuntime for ToolSearchHandler {} impl ToolSearchHandler { fn search( diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 22e0a16dd8..b4e7bd6acf 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -2,7 +2,6 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; use crate::shell::get_shell_by_model_provided_path; -use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -88,23 +87,19 @@ pub(crate) struct ResolvedCommand { fn post_unified_exec_tool_use_payload( invocation: &ToolInvocation, - result: &ExecCommandToolOutput, + result: &dyn ToolOutput, ) -> Option { let ToolPayload::Function { .. } = &invocation.payload else { return None; }; - let command = result.hook_command.clone()?; - let tool_use_id = if result.event_call_id.is_empty() { - invocation.call_id.clone() - } else { - result.event_call_id.clone() - }; + let tool_input = result.post_tool_use_input(&invocation.payload)?; + let tool_use_id = result.post_tool_use_id(&invocation.call_id); let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id, - tool_input: serde_json::json!({ "command": command }), + tool_input, tool_response, }) } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 2df924b5f4..b048227cf1 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -5,6 +5,7 @@ use crate::maybe_emit_implicit_skill_invocation; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; @@ -15,10 +16,10 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::rewrite_function_string_argument; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecError; @@ -70,8 +71,6 @@ impl ExecCommandHandler { #[async_trait::async_trait] impl ToolExecutor for ExecCommandHandler { - type Output = ExecCommandToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("exec_command") } @@ -90,7 +89,10 @@ impl ToolExecutor for ExecCommandHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -234,7 +236,7 @@ impl ToolExecutor for ExecCommandHandler { .await? { manager.release_process_id(process_id).await; - return Ok(ExecCommandToolOutput { + return Ok(boxed_tool_output(ExecCommandToolOutput { event_call_id: String::new(), chunk_id: String::new(), wall_time: std::time::Duration::ZERO, @@ -244,7 +246,7 @@ impl ToolExecutor for ExecCommandHandler { exit_code: None, original_token_count: None, hook_command: None, - }); + })); } emit_unified_exec_tty_metric(&turn.session_telemetry, tty); @@ -273,11 +275,11 @@ impl ToolExecutor for ExecCommandHandler { ) .await { - Ok(response) => Ok(response), + Ok(response) => Ok(boxed_tool_output(response)), Err(UnifiedExecError::SandboxDenied { output, .. }) => { let output_text = output.aggregated_output.text; let original_token_count = approx_token_count(&output_text); - Ok(ExecCommandToolOutput { + Ok(boxed_tool_output(ExecCommandToolOutput { event_call_id: context.call_id.clone(), chunk_id: generate_chunk_id(), wall_time: output.duration, @@ -289,7 +291,7 @@ impl ToolExecutor for ExecCommandHandler { exit_code: Some(output.exit_code), original_token_count: Some(original_token_count), hook_command: Some(hook_command), - }) + })) } Err(err) => Err(FunctionCallError::RespondToModel(format!( "exec_command failed for `{command_for_display}`: {err:?}" @@ -298,7 +300,7 @@ impl ToolExecutor for ExecCommandHandler { } } -impl ToolHandler for ExecCommandHandler { +impl CoreToolRuntime for ExecCommandHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -340,7 +342,7 @@ impl ToolHandler for ExecCommandHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { post_unified_exec_tool_use_payload(invocation, result) } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 29ee4b4ea3..3dea7092f5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -1,11 +1,11 @@ use crate::function_tool::FunctionCallError; -use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::unified_exec::WriteStdinRequest; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TerminalInteractionEvent; @@ -33,8 +33,6 @@ pub struct WriteStdinHandler; #[async_trait::async_trait] impl ToolExecutor for WriteStdinHandler { - type Output = ExecCommandToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("write_stdin") } @@ -43,7 +41,10 @@ impl ToolExecutor for WriteStdinHandler { Some(create_write_stdin_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -86,11 +87,11 @@ impl ToolExecutor for WriteStdinHandler { .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) .await; - Ok(response) + Ok(boxed_tool_output(response)) } } -impl ToolHandler for WriteStdinHandler { +impl CoreToolRuntime for WriteStdinHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -98,7 +99,7 @@ impl ToolHandler for WriteStdinHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { post_unified_exec_tool_use_payload(invocation, result) } diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 6ab2752b94..50a8e44214 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -13,7 +13,7 @@ use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; -use crate::tools::registry::ToolHandler; +use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use tokio::sync::Mutex; diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 7727fb4370..7ad6a79829 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -16,12 +16,13 @@ use crate::original_image_detail::can_request_original_image_detail; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -64,8 +65,6 @@ enum ViewImageDetail { #[async_trait::async_trait] impl ToolExecutor for ViewImageHandler { - type Output = ViewImageOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("view_image") } @@ -78,7 +77,10 @@ impl ToolExecutor for ViewImageHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { if !invocation .turn .model_info @@ -195,14 +197,14 @@ impl ToolExecutor for ViewImageHandler { session.emit_turn_item_started(turn.as_ref(), &item).await; session.emit_turn_item_completed(turn.as_ref(), item).await; - Ok(ViewImageOutput { + Ok(boxed_tool_output(ViewImageOutput { image_url, image_detail, - }) + })) } } -impl ToolHandler for ViewImageHandler {} +impl CoreToolRuntime for ViewImageHandler {} pub struct ViewImageOutput { image_url: String, diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 3073d9f9da..1effab52c3 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -13,7 +13,6 @@ pub(crate) mod runtimes; pub(crate) mod sandboxing; pub(crate) mod spec; pub(crate) mod spec_plan; -pub(crate) mod spec_plan_types; pub(crate) mod tool_dispatch_trace; pub(crate) mod tool_search_entry; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 730c3b7ef9..1cf0e00623 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -35,7 +34,11 @@ pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; pub use codex_tools::ToolExecutor; pub use codex_tools::ToolExposure; -pub trait ToolHandler: ToolExecutor { +/// Typed runtime contract for locally executed tools. +/// +/// Implementers provide the shared `ToolExecutor` behavior plus optional +/// core-owned metadata for hooks, telemetry, tool search, and argument diffs. +pub(crate) trait CoreToolRuntime: ToolExecutor { fn search_info(&self) -> Option { None } @@ -47,17 +50,17 @@ pub trait ToolHandler: ToolExecutor { ) } - fn telemetry_tags( - &self, - _invocation: &ToolInvocation, - ) -> impl Future + Send { - async { Vec::new() } + fn telemetry_tags<'a>( + &'a self, + _invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + Box::pin(async { Vec::new() }) } fn post_tool_use_payload( &self, _invocation: &ToolInvocation, - _result: &Self::Output, + _result: &dyn ToolOutput, ) -> Option { None } @@ -154,117 +157,10 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -/// Object-safe registry entry for heterogeneous tool handlers. -/// -/// Concrete handlers keep their typed `ToolExecutor::Output`; the registry -/// boxes that output only after typed hooks have run. -pub(crate) trait RegisteredTool: Send + Sync { - fn tool_name(&self) -> ToolName; - - fn spec(&self) -> Option; - - fn exposure(&self) -> ToolExposure; - - fn search_info(&self) -> Option; - - fn supports_parallel_tool_calls(&self) -> bool; - - fn matches_kind(&self, payload: &ToolPayload) -> bool; - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option; - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: Value, - ) -> Result; - - fn telemetry_tags<'a>( - &'a self, - invocation: &'a ToolInvocation, - ) -> BoxFuture<'a, ToolTelemetryTags>; - - fn create_diff_consumer(&self) -> Option>; - fn handle_any<'a>( - &'a self, - invocation: ToolInvocation, - ) -> BoxFuture<'a, Result>; -} - -impl RegisteredTool for T -where - T: ToolHandler, -{ - fn tool_name(&self) -> ToolName { - ToolExecutor::tool_name(self) - } - - fn spec(&self) -> Option { - ToolExecutor::spec(self) - } - - fn exposure(&self) -> ToolExposure { - ToolExecutor::exposure(self) - } - - fn search_info(&self) -> Option { - ToolHandler::search_info(self) - } - - fn supports_parallel_tool_calls(&self) -> bool { - ToolExecutor::supports_parallel_tool_calls(self) - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - ToolHandler::matches_kind(self, payload) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - ToolHandler::pre_tool_use_payload(self, invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: Value, - ) -> Result { - ToolHandler::with_updated_hook_input(self, invocation, updated_input) - } - - fn telemetry_tags<'a>( - &'a self, - invocation: &'a ToolInvocation, - ) -> BoxFuture<'a, ToolTelemetryTags> { - Box::pin(ToolHandler::telemetry_tags(self, invocation)) - } - - fn create_diff_consumer(&self) -> Option> { - ToolHandler::create_diff_consumer(self) - } - fn handle_any<'a>( - &'a self, - invocation: ToolInvocation, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let call_id = invocation.call_id.clone(); - let payload = invocation.payload.clone(); - let output = ToolExecutor::handle(self, invocation.clone()).await?; - let post_tool_use_payload = - ToolHandler::post_tool_use_payload(self, &invocation, &output); - Ok(AnyToolResult { - call_id, - payload, - result: Box::new(output), - post_tool_use_payload, - }) - }) - } -} - pub(crate) fn override_tool_exposure( - handler: Arc, + handler: Arc, exposure: ToolExposure, -) -> Arc { +) -> Arc { if handler.exposure() == exposure { return handler; } @@ -273,11 +169,12 @@ pub(crate) fn override_tool_exposure( } struct ExposureOverride { - handler: Arc, + handler: Arc, exposure: ToolExposure, } -impl RegisteredTool for ExposureOverride { +#[async_trait::async_trait] +impl ToolExecutor for ExposureOverride { fn tool_name(&self) -> ToolName { self.handler.tool_name() } @@ -290,14 +187,23 @@ impl RegisteredTool for ExposureOverride { self.exposure } - fn search_info(&self) -> Option { - self.handler.search_info() - } - fn supports_parallel_tool_calls(&self) -> bool { self.handler.supports_parallel_tool_calls() } + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + self.handler.handle(invocation).await + } +} + +impl CoreToolRuntime for ExposureOverride { + fn search_info(&self) -> Option { + self.handler.search_info() + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { self.handler.matches_kind(payload) } @@ -306,6 +212,14 @@ impl RegisteredTool for ExposureOverride { self.handler.pre_tool_use_payload(invocation) } + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &dyn ToolOutput, + ) -> Option { + self.handler.post_tool_use_payload(invocation, result) + } + fn with_updated_hook_input( &self, invocation: ToolInvocation, @@ -325,22 +239,28 @@ impl RegisteredTool for ExposureOverride { fn create_diff_consumer(&self) -> Option> { self.handler.create_diff_consumer() } - - fn handle_any<'a>( - &'a self, - invocation: ToolInvocation, - ) -> BoxFuture<'a, Result> { - self.handler.handle_any(invocation) - } } pub struct ToolRegistry { - handlers: HashMap>, + tools: HashMap>, } impl ToolRegistry { - fn new(handlers: HashMap>) -> Self { - Self { handlers } + fn new(tools: HashMap>) -> Self { + Self { tools } + } + + pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { + let mut tools_by_name = HashMap::new(); + for tool in tools { + let name = tool.tool_name(); + if tools_by_name.contains_key(&name) { + error_or_panic(format!("tool {name} already registered")); + continue; + } + tools_by_name.insert(name, tool); + } + Self::new(tools_by_name) } #[cfg(test)] @@ -351,35 +271,35 @@ impl ToolRegistry { #[cfg(test)] pub(crate) fn with_handler_for_test(handler: Arc) -> Self where - T: ToolHandler + 'static, + T: CoreToolRuntime + 'static, { let name = handler.tool_name(); - Self::new(HashMap::from([(name, handler as Arc)])) + Self::new(HashMap::from([(name, handler as Arc)])) } - fn handler(&self, name: &ToolName) -> Option> { - self.handlers.get(name).map(Arc::clone) + fn tool(&self, name: &ToolName) -> Option> { + self.tools.get(name).map(Arc::clone) } pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.handlers.get(name).map(|handler| handler.exposure()) + self.tools.get(name).map(|tool| tool.exposure()) } #[cfg(test)] - pub(crate) fn has_handler(&self, name: &ToolName) -> bool { - self.handler(name).is_some() + pub(crate) fn has_tool(&self, name: &ToolName) -> bool { + self.tool(name).is_some() } pub(crate) fn create_diff_consumer( &self, name: &ToolName, ) -> Option> { - self.handler(name)?.create_diff_consumer() + self.tool(name)?.create_diff_consumer() } pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option { - let handler = self.handler(name)?; - Some(handler.supports_parallel_tool_calls()) + let tool = self.tool(name)?; + Some(tool.supports_parallel_tool_calls()) } #[expect( @@ -422,8 +342,8 @@ impl ToolRegistry { } let dispatch_trace = ToolDispatchTrace::start(&invocation); - let handler = match self.handler(&tool_name) { - Some(handler) => handler, + let tool = match self.tool(&tool_name) { + Some(tool) => tool, None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); let log_payload = invocation.payload.log_payload(); @@ -443,7 +363,7 @@ impl ToolRegistry { } }; - let telemetry_tags = handler.telemetry_tags(&invocation).await; + let telemetry_tags = tool.telemetry_tags(&invocation).await; let mut tool_result_tags = Vec::with_capacity(base_tool_result_tags.len() + telemetry_tags.len()); let mut extra_trace_fields = Vec::new(); @@ -455,7 +375,7 @@ impl ToolRegistry { tool_result_tags.push((*key, value.as_str())); } } - if !handler.matches_kind(&invocation.payload) { + if !tool.matches_kind(&invocation.payload) { let message = format!("tool {tool_name} invoked with incompatible payload"); let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( @@ -473,7 +393,7 @@ impl ToolRegistry { return Err(err); } - if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) { + if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { match run_pre_tool_use_hooks( &invocation.session, &invocation.turn, @@ -491,7 +411,7 @@ impl ToolRegistry { PreToolUseHookResult::Continue { updated_input: Some(updated_input), } => { - invocation = handler.with_updated_hook_input(invocation, updated_input)?; + invocation = tool.with_updated_hook_input(invocation, updated_input)?; } PreToolUseHookResult::Continue { updated_input: None, @@ -511,10 +431,10 @@ impl ToolRegistry { &tool_result_tags, &extra_trace_fields, || { - let handler = handler.clone(); + let tool = tool.clone(); let response_cell = &response_cell; async move { - match handler.handle_any(invocation_for_tool).await { + match handle_any_tool(tool.as_ref(), invocation_for_tool).await { Ok(result) => { let preview = result.result.log_preview(); let success = result.result.success_for_logging(); @@ -620,52 +540,21 @@ impl ToolRegistry { } } -pub struct ToolRegistryBuilder { - handlers: HashMap>, - specs: Vec, -} - -impl ToolRegistryBuilder { - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - specs: Vec::new(), - } - } - - pub(crate) fn push_spec(&mut self, spec: ToolSpec) { - self.specs.push(spec); - } - - pub(crate) fn register_tool(&mut self, handler: Arc) { - self.register_tool_internal(handler, /*include_spec*/ true); - } - - pub(crate) fn register_tool_without_spec(&mut self, handler: Arc) { - self.register_tool_internal(handler, /*include_spec*/ false); - } - - fn register_tool_internal(&mut self, handler: Arc, include_spec: bool) { - let name = handler.tool_name(); - if self.handlers.contains_key(&name) { - error_or_panic(format!("handler for tool {name} already registered")); - return; - } - - if include_spec - && handler.exposure().is_direct() - && let Some(spec) = handler.spec() - { - self.push_spec(spec); - } - - self.handlers.insert(name, handler); - } - - pub fn build(self) -> (Vec, ToolRegistry) { - let registry = ToolRegistry::new(self.handlers); - (self.specs, registry) - } +async fn handle_any_tool( + tool: &dyn CoreToolRuntime, + invocation: ToolInvocation, +) -> Result { + let call_id = invocation.call_id.clone(); + let payload = invocation.payload.clone(); + let output = tool.handle(invocation.clone()).await?; + let post_tool_use_payload = + CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref()); + Ok(AnyToolResult { + call_id, + payload, + result: output, + post_tool_use_payload, + }) } fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index b6c8eadadf..defacf33c0 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,7 +1,4 @@ use super::*; -use crate::tools::handlers::GetGoalHandler; -use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_get_goal_tool; use pretty_assertions::assert_eq; struct TestHandler { @@ -10,21 +7,21 @@ struct TestHandler { #[async_trait::async_trait] impl ToolExecutor for TestHandler { - type Output = crate::tools::context::FunctionToolOutput; - fn tool_name(&self) -> codex_tools::ToolName { self.tool_name.clone() } - async fn handle(&self, _invocation: ToolInvocation) -> Result { - Ok(crate::tools::context::FunctionToolOutput::from_text( - "ok".to_string(), - Some(true), + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new( + crate::tools::context::FunctionToolOutput::from_text("ok".to_string(), Some(true)), )) } } -impl ToolHandler for TestHandler {} +impl CoreToolRuntime for TestHandler {} #[test] fn handler_looks_up_namespaced_aliases_explicitly() { @@ -34,18 +31,18 @@ fn handler_looks_up_namespaced_aliases_explicitly() { let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); let plain_handler = Arc::new(TestHandler { tool_name: plain_name.clone(), - }) as Arc; + }) as Arc; let namespaced_handler = Arc::new(TestHandler { tool_name: namespaced_name.clone(), - }) as Arc; + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), ])); - let plain = registry.handler(&plain_name); - let namespaced = registry.handler(&namespaced_name); - let missing_namespaced = registry.handler(&codex_tools::ToolName::namespaced( + let plain = registry.tool(&plain_name); + let namespaced = registry.tool(&namespaced_name); + let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced( "mcp__codex_apps__calendar", tool_name, )); @@ -64,15 +61,3 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } - -#[test] -fn register_tool_adds_executor_and_spec() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_tool(Arc::new(GetGoalHandler)); - - let (specs, registry) = builder.build(); - - assert_eq!(specs.len(), 1); - assert_eq!(specs[0], create_get_goal_tool()); - assert!(registry.has_handler(&codex_tools::ToolName::plain(GET_GOAL_TOOL_NAME))); -} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 0f111922c6..2477ba347c 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,18 +5,16 @@ use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; -use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolArgumentDiffConsumer; -use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; -use crate::tools::spec::collect_tool_router_parts; -use crate::tools::spec_plan::build_tool_registry_builder_from_executors; -use codex_extension_api::ExtensionToolExecutor; +use crate::tools::spec_plan::build_tool_router; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_tools::DiscoverableTool; +use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; @@ -42,42 +40,16 @@ pub(crate) struct ToolRouterParams<'a> { pub(crate) mcp_tools: Option>, pub(crate) deferred_mcp_tools: Option>, pub(crate) discoverable_tools: Option>, - pub(crate) extension_tool_executors: Vec>, + pub(crate) extension_tool_executors: Vec>>, pub(crate) dynamic_tools: &'a [DynamicToolSpec], } impl ToolRouter { pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { - let ToolRouterParams { - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors, - dynamic_tools, - } = params; - let parts = collect_tool_router_parts( - config, - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - &extension_tool_executors, - dynamic_tools, - ); - Self::from_executors(config, parts.executors, parts.hosted_specs) + build_tool_router(config, params) } - pub(crate) fn from_executors( - config: &ToolsConfig, - executors: Vec>, - hosted_specs: Vec, - ) -> Self { - let builder = build_tool_registry_builder_from_executors(config, executors, hosted_specs); - let (specs, registry) = builder.build(); - let model_visible_specs = specs - .into_iter() - .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) - .collect(); - + pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec) -> Self { Self { registry, model_visible_specs, @@ -182,22 +154,9 @@ impl ToolRouter { } } -fn is_hidden_by_code_mode_only( - config: &ToolsConfig, - registry: &ToolRegistry, - spec: &ToolSpec, -) -> bool { - if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { - return false; - } - - let exposure = registry - .tool_exposure(&ToolName::plain(spec.name())) - .unwrap_or(ToolExposure::Direct); - exposure != ToolExposure::DirectModelOnly -} - -pub(crate) fn extension_tool_executors(session: &Session) -> Vec> { +pub(crate) fn extension_tool_executors( + session: &Session, +) -> Vec>> { session .services .extensions diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index dd791d9135..c9bb4bcafc 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -7,7 +7,6 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; @@ -37,7 +36,7 @@ impl codex_extension_api::ToolContributor for ExtensionEchoContributor { &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, - ) -> Vec> { + ) -> Vec>> { vec![Arc::new(ExtensionEchoExecutor)] } } @@ -46,8 +45,6 @@ struct ExtensionEchoExecutor; #[async_trait::async_trait] impl ToolExecutor for ExtensionEchoExecutor { - type Output = codex_tools::JsonToolOutput; - fn tool_name(&self) -> ToolName { ToolName::namespaced("extension/", "echo") } @@ -78,14 +75,14 @@ impl ToolExecutor for ExtensionEchoExecutor { async fn handle( &self, call: ExtensionToolCall, - ) -> Result { + ) -> Result, codex_tools::FunctionCallError> { let arguments: serde_json::Value = serde_json::from_str(call.function_arguments()?).expect("test arguments should parse"); - Ok(codex_tools::JsonToolOutput::new(json!({ + Ok(Box::new(codex_tools::JsonToolOutput::new(json!({ "arguments": arguments, "callId": call.call_id, "ok": true, - }))) + })))) } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 64dfdb8316..7ee05c540b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,23 +1,6 @@ -use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::shell::Shell; use crate::shell::ShellType; -use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use crate::tools::registry::RegisteredTool; -use crate::tools::spec_plan::collect_tool_executors; -use crate::tools::spec_plan::hosted_model_tool_specs; -use crate::tools::spec_plan_types::ToolRegistryBuildParams; -use codex_extension_api::ExtensionToolExecutor; -use codex_mcp::ToolInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tools::DiscoverableTool; use codex_tools::ToolUserShellType; -use codex_tools::ToolsConfig; -use std::sync::Arc; pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { match user_shell.shell_type { @@ -29,66 +12,6 @@ pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { } } -pub(crate) struct ToolRouterParts { - pub(crate) executors: Vec>, - pub(crate) hosted_specs: Vec, -} - -pub(crate) fn collect_tool_router_parts( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc], - dynamic_tools: &[DynamicToolSpec], -) -> ToolRouterParts { - let default_agent_type_description = - crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); - let (min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms) = - if config.multi_agent_v2 { - let min_wait_timeout_ms = config - .wait_agent_min_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS); - let max_wait_timeout_ms = config - .wait_agent_max_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS); - let default_wait_timeout_ms = config - .wait_agent_default_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS); - ( - min_wait_timeout_ms, - max_wait_timeout_ms, - default_wait_timeout_ms, - ) - } else { - ( - MIN_WAIT_TIMEOUT_MS, - MAX_WAIT_TIMEOUT_MS, - DEFAULT_WAIT_TIMEOUT_MS, - ) - }; - let executors = collect_tool_executors( - config, - ToolRegistryBuildParams { - mcp_tools: mcp_tools.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors, - dynamic_tools, - default_agent_type_description: &default_agent_type_description, - wait_agent_timeouts: WaitAgentTimeoutOptions { - default_timeout_ms: default_wait_timeout_ms, - min_timeout_ms: min_wait_timeout_ms, - max_timeout_ms: max_wait_timeout_ms, - }, - }, - ); - ToolRouterParts { - executors, - hosted_specs: hosted_model_tool_specs(config), - } -} - #[cfg(test)] #[path = "spec_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 24f59a0f04..679772f4c7 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,3 +1,6 @@ +use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; @@ -24,13 +27,17 @@ use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; -use crate::tools::handlers::extension_tools::ExtensionToolHandler; +use crate::tools::handlers::extension_tools::ExtensionToolAdapter; use crate::tools::handlers::multi_agents::CloseAgentHandler; use crate::tools::handlers::multi_agents::ResumeAgentHandler; use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; +use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; +use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; @@ -41,17 +48,21 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; use crate::tools::hosted_spec::create_web_search_tool; -use crate::tools::registry::RegisteredTool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExposure; -use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::ToolRegistry; use crate::tools::registry::override_tool_exposure; -use crate::tools::spec_plan_types::ToolRegistryBuildParams; -use crate::tools::spec_plan_types::agent_type_description; -use codex_extension_api::ExtensionToolExecutor; +use crate::tools::router::ToolRouter; +use crate::tools::router::ToolRouterParams; +use codex_mcp::ToolInfo; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; +use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironmentMode; +use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; @@ -62,74 +73,94 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::warn; -pub(crate) fn build_tool_registry_builder_from_executors( +#[derive(Clone, Copy)] +struct ToolRegistryBuildParams<'a> { + mcp_tools: Option<&'a [ToolInfo]>, + deferred_mcp_tools: Option<&'a [ToolInfo]>, + discoverable_tools: Option<&'a [DiscoverableTool]>, + extension_tool_executors: &'a [Arc>], + dynamic_tools: &'a [DynamicToolSpec], + default_agent_type_description: &'a str, + wait_agent_timeouts: WaitAgentTimeoutOptions, +} + +pub(crate) fn build_tool_router(config: &ToolsConfig, params: ToolRouterParams<'_>) -> ToolRouter { + let (model_visible_specs, registry) = build_tool_specs_and_registry(config, params); + ToolRouter::from_parts(registry, model_visible_specs) +} + +fn build_tool_specs_and_registry( config: &ToolsConfig, - executors: Vec>, - hosted_specs: Vec, -) -> ToolRegistryBuilder { - let mut builder = ToolRegistryBuilder::new(); - let deferred_tools_available = executors - .iter() - .any(|executor| executor.exposure() == ToolExposure::Deferred); - - for executor in build_code_mode_executors( + params: ToolRouterParams<'_>, +) -> (Vec, ToolRegistry) { + let ToolRouterParams { + mcp_tools, + deferred_mcp_tools, + discoverable_tools, + extension_tool_executors, + dynamic_tools, + } = params; + let default_agent_type_description = + crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); + let mut executors = collect_tool_executors( config, - &executors, - config.search_tool && deferred_tools_available, - ) { - builder.register_tool(executor); - } - - let mut non_deferred_specs = Vec::new(); - let mut deferred_search_infos = Vec::new(); - for executor in &executors { - match executor.exposure() { - ToolExposure::Direct | ToolExposure::DirectModelOnly => { - if let Some(spec) = executor.spec() { - non_deferred_specs.push((spec, executor.exposure())); - } - } - ToolExposure::Deferred => { - if let Some(search_info) = executor.search_info() { - deferred_search_infos.push(search_info); - } - } - } - } - - non_deferred_specs.extend( - hosted_specs - .into_iter() - .map(|spec| (spec, ToolExposure::Direct)), + ToolRegistryBuildParams { + mcp_tools: mcp_tools.as_deref(), + deferred_mcp_tools: deferred_mcp_tools.as_deref(), + discoverable_tools: discoverable_tools.as_deref(), + extension_tool_executors: &extension_tool_executors, + dynamic_tools, + default_agent_type_description: &default_agent_type_description, + wait_agent_timeouts: wait_agent_timeout_options(config), + }, ); + append_tool_search_executor(config, &mut executors); + prepend_code_mode_executors(config, &mut executors); + build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) +} - let non_deferred_specs = non_deferred_specs - .into_iter() - .map(|(spec, exposure)| { - if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly { - codex_tools::augment_tool_spec_for_code_mode(spec) - } else { - spec - } - }) - .collect(); - - for spec in merge_into_namespaces(non_deferred_specs) { - if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) { +fn build_model_visible_specs_and_registry( + config: &ToolsConfig, + executors: Vec>, + hosted_specs: Vec, +) -> (Vec, ToolRegistry) { + let mut specs = Vec::new(); + let mut seen_tool_names = HashSet::new(); + for executor in &executors { + if !seen_tool_names.insert(executor.tool_name()) { continue; } - builder.push_spec(spec); + if executor.exposure().is_direct() + && let Some(spec) = executor.spec() + { + specs.push(spec_for_model_request(config, executor.exposure(), spec)); + } } + specs.extend(hosted_specs); - for executor in executors { - builder.register_tool_without_spec(executor); + let registry = ToolRegistry::from_tools(executors); + let model_visible_specs = merge_into_namespaces(specs) + .into_iter() + .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) + .collect(); + + (model_visible_specs, registry) +} + +fn spec_for_model_request( + config: &ToolsConfig, + exposure: ToolExposure, + spec: ToolSpec, +) -> ToolSpec { + if config.code_mode_enabled + && exposure != ToolExposure::DirectModelOnly + && codex_code_mode::is_code_mode_nested_tool(spec.name()) + { + codex_tools::augment_tool_spec_for_code_mode(spec) + } else { + spec } - - if config.search_tool && config.namespace_tools && !deferred_search_infos.is_empty() { - builder.register_tool(Arc::new(ToolSearchHandler::new(deferred_search_infos))); - } - - builder } pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { @@ -147,11 +178,56 @@ pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { specs } +fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { + if config.multi_agent_v2 { + return WaitAgentTimeoutOptions { + default_timeout_ms: config + .wait_agent_default_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS), + min_timeout_ms: config + .wait_agent_min_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS), + max_timeout_ms: config + .wait_agent_max_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS), + }; + } + + WaitAgentTimeoutOptions { + default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS, + min_timeout_ms: MIN_WAIT_TIMEOUT_MS, + max_timeout_ms: MAX_WAIT_TIMEOUT_MS, + } +} + +fn agent_type_description(config: &ToolsConfig, default_agent_type_description: &str) -> String { + if config.agent_type_description.is_empty() { + default_agent_type_description.to_string() + } else { + config.agent_type_description.clone() + } +} + +fn is_hidden_by_code_mode_only( + config: &ToolsConfig, + registry: &ToolRegistry, + spec: &ToolSpec, +) -> bool { + if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { + return false; + } + + let exposure = registry + .tool_exposure(&ToolName::plain(spec.name())) + .unwrap_or(ToolExposure::Direct); + exposure != ToolExposure::DirectModelOnly +} + fn build_code_mode_executors( config: &ToolsConfig, - executors: &[Arc], + executors: &[Arc], deferred_tools_available: bool, -) -> Vec> { +) -> Vec> { if !config.code_mode_enabled { return vec![]; } @@ -254,12 +330,12 @@ fn code_mode_namespace_descriptions( namespace_descriptions } -pub(crate) fn collect_tool_executors( +fn collect_tool_executors( config: &ToolsConfig, params: ToolRegistryBuildParams<'_>, -) -> Vec> { +) -> Vec> { let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; - let mut executors = Vec::>::new(); + let mut executors = Vec::>::new(); if config.environment_mode.has_environment() { let include_environment_id = @@ -444,10 +520,43 @@ pub(crate) fn collect_tool_executors( executors } +fn append_tool_search_executor( + config: &ToolsConfig, + executors: &mut Vec>, +) { + if !(config.search_tool && config.namespace_tools) { + return; + } + + let search_infos = executors + .iter() + .filter(|executor| executor.exposure() == ToolExposure::Deferred) + .filter_map(|executor| executor.search_info()) + .collect::>(); + if search_infos.is_empty() { + return; + } + + executors.push(Arc::new(ToolSearchHandler::new(search_infos))); +} + +fn prepend_code_mode_executors( + config: &ToolsConfig, + executors: &mut Vec>, +) { + let deferred_tools_available = config.search_tool + && executors + .iter() + .any(|executor| executor.exposure() == ToolExposure::Deferred); + let code_mode_executors = + build_code_mode_executors(config, executors, deferred_tools_available); + executors.splice(0..0, code_mode_executors); +} + fn append_extension_tool_executors( config: &ToolsConfig, - executors: &[Arc], - registered_executors: &mut Vec>, + executors: &[Arc>], + registered_executors: &mut Vec>, ) { if executors.is_empty() { return; @@ -473,17 +582,17 @@ fn append_extension_tool_executors( for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); if !reserved_tool_names.insert(tool_name.clone()) { - warn!("Skipping extension tool `{tool_name}`: handler already registered"); + warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; } - registered_executors.push(Arc::new(ExtensionToolHandler::new(executor))); + registered_executors.push(Arc::new(ExtensionToolAdapter::new(executor))); } } fn multi_agent_v2_handler( - handler: impl RegisteredTool + 'static, + handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, -) -> Arc { +) -> Arc { override_tool_exposure(Arc::new(handler), exposure) } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index dd690b5631..063d75e6e8 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -26,7 +26,6 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; use crate::tools::registry::ToolRegistry; use codex_app_server_protocol::AppInfo; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; use codex_features::Feature; @@ -74,7 +73,10 @@ const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000; -fn extension_tool_executor(name: &str, description: &str) -> Arc { +fn extension_tool_executor( + name: &str, + description: &str, +) -> Arc> { struct SpecOnlyExtensionExecutor { name: String, description: String, @@ -82,8 +84,6 @@ fn extension_tool_executor(name: &str, description: &str) -> Arc for SpecOnlyExtensionExecutor { - type Output = codex_tools::JsonToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(self.name.as_str()) } @@ -109,7 +109,7 @@ fn extension_tool_executor(name: &str, description: &str) -> Arc Result { + ) -> Result, codex_tools::FunctionCallError> { panic!("spec planning should not execute extension tools") } } @@ -1440,7 +1440,7 @@ fn namespace_specs_are_hidden_when_namespace_tools_are_disabled() { ); assert_lacks_tool_name(&tools, "mcp__sample__"); - assert!(registry.has_handler(&ToolName::namespaced("mcp__sample__", "echo"))); + assert!(registry.has_tool(&ToolName::namespaced("mcp__sample__", "echo"))); } #[test] @@ -1646,11 +1646,11 @@ fn search_tool_description_lists_each_mcp_source_once() { assert!(description.contains("- rmcp: Remote memory tools.")); assert!(!description.contains("mcp__rmcp__echo")); - assert!(registry.has_handler(&ToolName::namespaced( + assert!(registry.has_tool(&ToolName::namespaced( "mcp__codex_apps__calendar", "_create_event", ))); - assert!(registry.has_handler(&ToolName::namespaced("mcp__rmcp__", "echo"))); + assert!(registry.has_tool(&ToolName::namespaced("mcp__rmcp__", "echo"))); } #[test] @@ -1758,7 +1758,7 @@ fn no_search_tool_when_namespaces_disabled() { ); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert!(!registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); } #[test] @@ -1816,9 +1816,9 @@ fn search_tool_registers_for_deferred_dynamic_tools() { assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread.")); assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); assert_lacks_tool_name(&tools, "codex_app"); - assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_list"))); + assert!(registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_list"))); } #[test] @@ -1865,9 +1865,9 @@ fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_dis assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); assert_lacks_tool_name(&tools, "codex_app"); assert_lacks_tool_name(&tools, "plain_dynamic"); - assert!(!registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_handler(&ToolName::plain("plain_dynamic"))); + assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); + assert!(registry.has_tool(&ToolName::plain("plain_dynamic"))); } #[test] @@ -2007,7 +2007,7 @@ fn request_plugin_install_description_lists_discoverable_tools() { /*extension_tool_executors*/ &[], &[], ); - assert!(registry.has_handler(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { @@ -2109,18 +2109,19 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { &[], ); - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "echo"); + let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { + panic!("expected freeform tool"); + }; - assert_eq!( - description, - r#"Echo text + assert!(description.contains( + r#"### `mcp__sample__echo` +Echo text exec tool declaration: ```ts declare const tools: { mcp__sample__echo(args: { message: string; }): Promise; }; ```"# - ); + )); } #[test] @@ -2413,7 +2414,7 @@ fn build_specs_with_inputs_for_test( mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, - extension_tool_executors: &[Arc], + extension_tool_executors: &[Arc>], dynamic_tools: &[DynamicToolSpec], ) -> (Vec, ToolRegistry) { let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| { @@ -2431,13 +2432,10 @@ fn build_specs_with_inputs_for_test( default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION, wait_agent_timeouts: wait_agent_timeout_options(), }; - let executors = collect_tool_executors(config, params); - let builder = build_tool_registry_builder_from_executors( - config, - executors, - hosted_model_tool_specs(config), - ); - builder.build() + let mut executors = collect_tool_executors(config, params); + append_tool_search_executor(config, &mut executors); + prepend_code_mode_executors(config, &mut executors); + build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) } fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { @@ -2539,18 +2537,19 @@ fn code_mode_augments_mcp_tool_descriptions_with_structured_output_sample() { &[], ); - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "echo"); + let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { + panic!("expected freeform tool"); + }; - assert_eq!( - description, - r#"Echo text + assert!(description.contains( + r#"### `mcp__sample__echo` +Echo text exec tool declaration: ```ts declare const tools: { mcp__sample__echo(args: { message: string; }): Promise>; }; ```"# - ); + )); } fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { diff --git a/codex-rs/core/src/tools/spec_plan_types.rs b/codex-rs/core/src/tools/spec_plan_types.rs deleted file mode 100644 index 247fa05715..0000000000 --- a/codex-rs/core/src/tools/spec_plan_types.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use codex_extension_api::ExtensionToolExecutor; -use codex_mcp::ToolInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tools::DiscoverableTool; -use codex_tools::ToolsConfig; -use std::sync::Arc; - -#[derive(Clone, Copy)] -pub struct ToolRegistryBuildParams<'a> { - pub mcp_tools: Option<&'a [ToolInfo]>, - pub deferred_mcp_tools: Option<&'a [ToolInfo]>, - pub discoverable_tools: Option<&'a [DiscoverableTool]>, - pub extension_tool_executors: &'a [Arc], - pub dynamic_tools: &'a [DynamicToolSpec], - pub default_agent_type_description: &'a str, - pub wait_agent_timeouts: WaitAgentTimeoutOptions, -} - -pub(crate) fn agent_type_description( - config: &ToolsConfig, - default_agent_type_description: &str, -) -> String { - if config.agent_type_description.is_empty() { - default_agent_type_description.to_string() - } else { - config.agent_type_description.clone() - } -} diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 8653810ba4..c74a795793 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -3,17 +3,17 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::test_support::construct_model_info_offline; use crate::tools::ToolRouter; -use crate::tools::registry::ToolRegistryBuilder; use crate::tools::router::ToolRouterParams; -use crate::tools::spec_plan::build_tool_registry_builder_from_executors; use codex_app_server_protocol::AppInfo; use codex_features::Feature; use codex_features::Features; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::ToolInfo; use codex_models_manager::bundled_models_response; use codex_models_manager::model_info::with_config_overrides; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -56,21 +56,6 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r } } -fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo { - ToolInfo { - server_name: "test_server".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: tool.name.to_string(), - callable_namespace: "mcp__test_server__".to_string(), - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo { let (callable_namespace, callable_name) = display_name .rsplit_once('/') @@ -240,13 +225,12 @@ async fn multi_agent_v2_tools_config() -> ToolsConfig { } fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String { - let (tools, _) = build_specs( + let tools = build_specs( tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let spawn_agent = find_tool(&tools, "spawn_agent"); let ToolSpec::Function(ResponsesApiTool { description, .. }) = spawn_agent else { panic!("spawn_agent should be a function tool"); @@ -266,13 +250,13 @@ async fn model_info_from_models_json(slug: &str) -> ModelInfo { with_config_overrides(model, &config.to_models_manager_config()) } -/// Builds the tool registry builder while collecting tool specs for later serialization. +/// Builds tool specs and the matching registry from the same executor list. fn build_specs( config: &ToolsConfig, mcp_tools: Option>, deferred_mcp_tools: Option>, dynamic_tools: &[DynamicToolSpec], -) -> ToolRegistryBuilder { +) -> Vec { build_specs_with_inputs_for_test( config, mcp_tools, @@ -288,18 +272,22 @@ fn build_specs_with_inputs_for_test( mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, - extension_tool_executors: &[Arc], + extension_tool_executors: &[Arc< + dyn codex_extension_api::ToolExecutor, + >], dynamic_tools: &[DynamicToolSpec], -) -> ToolRegistryBuilder { - let parts = collect_tool_router_parts( +) -> Vec { + ToolRouter::from_config( config, - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors, - dynamic_tools, - ); - build_tool_registry_builder_from_executors(config, parts.executors, parts.hosted_specs) + ToolRouterParams { + mcp_tools, + deferred_mcp_tools, + discoverable_tools, + extension_tool_executors: extension_tool_executors.to_vec(), + dynamic_tools, + }, + ) + .model_visible_specs() } #[tokio::test] @@ -319,13 +307,12 @@ async fn get_memory_requires_feature_flag() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); assert!( !tools.iter().any(|t| t.name() == "get_memory"), "get_memory should be disabled when memory_tool feature is off" @@ -633,13 +620,12 @@ async fn test_build_specs_default_shell_present() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(Vec::new()), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); // Only check the shell variant and a couple of core tools. let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; @@ -774,13 +760,12 @@ async fn multi_agent_v2_wait_agent_schema_uses_configured_timeouts() { .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms) .with_wait_agent_max_timeout_ms(wait_agent_max_timeout_ms) .with_wait_agent_default_timeout_ms(wait_agent_default_timeout_ms); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let wait_agent = find_tool(&tools, "wait_agent"); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = wait_agent else { panic!("wait_agent should be a function tool"); @@ -825,15 +810,14 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs_with_inputs_for_test( + let tools = build_specs_with_inputs_for_test( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, discoverable_tools.clone(), /*extension_tool_executors*/ &[], &[], - ) - .build(); + ); assert!( !tools @@ -862,13 +846,12 @@ async fn search_tool_is_hidden_without_deferred_tools() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, Some(Vec::new()), &[], - ) - .build(); + ); assert!( !tools .iter() @@ -894,7 +877,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, Some(vec![ToolInfo { @@ -914,8 +897,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio plugin_display_names: Vec::new(), }]), &[], - ) - .build(); + ); let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); let ToolSpec::ToolSearch { description, .. } = search_tool else { panic!("expected tool_search tool"); @@ -925,119 +907,6 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio assert!(!description.contains("- Calendar:")); } -#[tokio::test] -async fn search_tool_registers_namespaced_mcp_tool_aliases() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (_, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_create_event".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar-create-event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_list_events".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar-list-events", - "List calendar events", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - ToolInfo { - server_name: "rmcp".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "echo".to_string(), - callable_namespace: "mcp__rmcp__".to_string(), - namespace_description: None, - tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - }, - ]), - &[], - ) - .build(); - - let app_alias = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"); - let mcp_alias = ToolName::namespaced("mcp__rmcp__", "echo"); - - assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&app_alias)); - assert!(registry.has_handler(&mcp_alias)); -} - -#[tokio::test] -async fn direct_mcp_tools_register_namespaced_handlers() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (_, registry) = build_specs( - &tools_config, - Some(vec![mcp_tool_info(mcp_tool( - "echo", - "Echo", - serde_json::json!({"type": "object"}), - ))]), - /*deferred_mcp_tools*/ None, - &[], - ) - .build(); - - assert!(registry.has_handler(&ToolName::namespaced("mcp__test_server__", "echo"))); - assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo"))); -} - #[tokio::test] async fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config().await; @@ -1056,7 +925,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/search", @@ -1073,8 +942,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "search"); assert_eq!( @@ -1116,7 +984,7 @@ async fn test_mcp_tool_preserves_integer_schema() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/paginate", @@ -1131,8 +999,7 @@ async fn test_mcp_tool_preserves_integer_schema() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "paginate"); assert_eq!( @@ -1174,7 +1041,7 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/tags", @@ -1189,8 +1056,7 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "tags"); assert_eq!( @@ -1235,7 +1101,7 @@ async fn test_mcp_tool_anyof_defaults_to_string() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/value", @@ -1252,8 +1118,7 @@ async fn test_mcp_tool_anyof_defaults_to_string() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "value"); assert_eq!( @@ -1300,7 +1165,7 @@ async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "test_server/do_something_cool", @@ -1334,8 +1199,7 @@ async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); assert_eq!( diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index cb0eff9ca9..bccaf60c82 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -21,8 +21,8 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolRegistry; use crate::turn_diff_tracker::TurnDiffTracker; @@ -32,18 +32,22 @@ struct TestHandler { #[async_trait::async_trait] impl ToolExecutor for TestHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> codex_tools::ToolName { self.tool_name.clone() } - async fn handle(&self, _invocation: ToolInvocation) -> Result { - Ok(FunctionToolOutput::from_text("ok".to_string(), Some(true))) + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) } } -impl ToolHandler for TestHandler {} +impl CoreToolRuntime for TestHandler {} #[tokio::test] async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> anyhow::Result<()> { diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index ed9665f228..a8c2001200 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -4,12 +4,13 @@ use std::sync::Arc; use codex_protocol::items::TurnItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; +use codex_tools::ToolCall; +use codex_tools::ToolExecutor; use crate::ExtensionData; mod prompt; mod thread_lifecycle; -mod tools; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -17,8 +18,6 @@ pub use prompt::PromptSlot; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; -pub use tools::ExtensionToolExecutor; -pub use tools::ExtensionToolOutput; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -106,7 +105,7 @@ pub trait ToolContributor: Send + Sync { &self, session_store: &ExtensionData, thread_store: &ExtensionData, - ) -> Vec>; + ) -> Vec>>; } /// Future returned by one claimed approval-review contribution. diff --git a/codex-rs/ext/extension-api/src/contributors/tools.rs b/codex-rs/ext/extension-api/src/contributors/tools.rs deleted file mode 100644 index a9f0f6dc32..0000000000 --- a/codex-rs/ext/extension-api/src/contributors/tools.rs +++ /dev/null @@ -1,15 +0,0 @@ -use codex_tools::JsonToolOutput; -use codex_tools::ToolCall; -use codex_tools::ToolExecutor; - -/// Model-facing output returned by extension-owned tools. -pub type ExtensionToolOutput = JsonToolOutput; - -/// Thin alias for extension-owned executable tools. -/// -/// Extensions implement the shared `ToolExecutor` contract directly; -/// the marker keeps contributor signatures readable while preserving one -/// executable-tool abstraction across host and extension tools. -pub trait ExtensionToolExecutor: ToolExecutor {} - -impl ExtensionToolExecutor for T where T: ToolExecutor {} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index a6d850468b..e8be7e0309 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -11,6 +11,7 @@ pub use codex_tools::ResponsesApiTool; pub use codex_tools::ToolCall; pub use codex_tools::ToolExecutor; pub use codex_tools::ToolName; +pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; pub use codex_tools::parse_tool_input_schema; @@ -18,8 +19,6 @@ pub use contributors::ApprovalReviewContributor; pub use contributors::ApprovalReviewFuture; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; -pub use contributors::ExtensionToolExecutor; -pub use contributors::ExtensionToolOutput; pub use contributors::PromptFragment; pub use contributors::PromptSlot; pub use contributors::ThreadLifecycleContributor; diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 2b49559183..9226ddfdcc 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -63,7 +63,7 @@ impl ToolContributor for MemoriesExtension { &self, _session_store: &ExtensionData, thread_store: &ExtensionData, - ) -> Vec> { + ) -> Vec>> { let Some(config) = thread_store.get::() else { return Vec::new(); }; diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index 47c58f4686..b732ca22c2 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::PromptSlot; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; +use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolPayload; use codex_tools::ToolOutput; @@ -290,20 +290,23 @@ async fn search_tool_rejects_legacy_single_query() { .to_string(), }; - let err = tool + let result = tool .handle(ToolCall { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), payload, }) - .await - .expect_err("legacy query field should be rejected"); + .await; + let err = match result { + Ok(_) => panic!("legacy query field should be rejected"), + Err(err) => err, + }; assert!(err.to_string().contains("unknown field")); assert!(err.to_string().contains("query")); } -fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc { +fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc> { let expected_tool_name = memory_tool_name(tool_name); crate::tools::memory_tools(LocalMemoriesBackend::from_memory_root(memory_root)) .into_iter() diff --git a/codex-rs/ext/memories/src/tools/list.rs b/codex-rs/ext/memories/src/tools/list.rs index 1509c2344c..baed3c30a2 100644 --- a/codex-rs/ext/memories/src/tools/list.rs +++ b/codex-rs/ext/memories/src/tools/list.rs @@ -39,8 +39,6 @@ impl ToolExecutor for ListTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(LIST_TOOL_NAME) } @@ -55,7 +53,8 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: ListArgs = parse_args(&call)?; let response = backend @@ -70,6 +69,6 @@ where }) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/ext/memories/src/tools/mod.rs b/codex-rs/ext/memories/src/tools/mod.rs index f95922b28f..b78a125652 100644 --- a/codex-rs/ext/memories/src/tools/mod.rs +++ b/codex-rs/ext/memories/src/tools/mod.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::FunctionCallError; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; @@ -23,7 +23,7 @@ mod list; mod read; mod search; -pub(crate) fn memory_tools(backend: B) -> Vec> +pub(crate) fn memory_tools(backend: B) -> Vec>> where B: MemoriesBackend, { diff --git a/codex-rs/ext/memories/src/tools/read.rs b/codex-rs/ext/memories/src/tools/read.rs index 06d8a1752c..6e7f771d75 100644 --- a/codex-rs/ext/memories/src/tools/read.rs +++ b/codex-rs/ext/memories/src/tools/read.rs @@ -38,8 +38,6 @@ impl ToolExecutor for ReadTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(READ_TOOL_NAME) } @@ -54,7 +52,8 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: ReadArgs = parse_args(&call)?; let response = backend @@ -66,6 +65,6 @@ where }) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/ext/memories/src/tools/search.rs b/codex-rs/ext/memories/src/tools/search.rs index f7cab7de6c..422ce06b7e 100644 --- a/codex-rs/ext/memories/src/tools/search.rs +++ b/codex-rs/ext/memories/src/tools/search.rs @@ -47,8 +47,6 @@ impl ToolExecutor for SearchTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(SEARCH_TOOL_NAME) } @@ -63,14 +61,15 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: SearchArgs = parse_args(&call)?; let response = backend .search(args.into_request()) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs index 62237740ba..2557dc3490 100644 --- a/codex-rs/tools/src/tool_executor.rs +++ b/codex-rs/tools/src/tool_executor.rs @@ -36,8 +36,6 @@ impl ToolExposure { /// top without reopening the spec/runtime split. #[async_trait::async_trait] pub trait ToolExecutor: Send + Sync { - type Output: ToolOutput + 'static; - /// The concrete tool name handled by this runtime instance. fn tool_name(&self) -> ToolName; @@ -53,5 +51,8 @@ pub trait ToolExecutor: Send + Sync { false } - async fn handle(&self, invocation: Invocation) -> Result; + async fn handle( + &self, + invocation: Invocation, + ) -> Result, FunctionCallError>; } diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index 2044295174..64a8164da3 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -20,6 +20,16 @@ pub trait ToolOutput: Send { fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; + /// Returns the tool call id exposed to `PostToolUse` hooks for this output. + fn post_tool_use_id(&self, call_id: &str) -> String { + call_id.to_string() + } + + /// Returns the tool input exposed to `PostToolUse` hooks for this output. + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + None + } + /// Returns the stable value exposed to `PostToolUse` hooks for this tool output. /// /// Tool handlers decide whether a tool participates in `PostToolUse`, but @@ -52,6 +62,14 @@ where (**self).to_response_item(call_id, payload) } + fn post_tool_use_id(&self, call_id: &str) -> String { + (**self).post_tool_use_id(call_id) + } + + fn post_tool_use_input(&self, payload: &ToolPayload) -> Option { + (**self).post_tool_use_input(payload) + } + fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option { (**self).post_tool_use_response(call_id, payload) } From c03cea4ca2a8a582b309a483d84a094c75cfd1c7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 13:44:58 +0200 Subject: [PATCH 007/114] Remove zombie tools spec module (#22820) ## Summary - move tool_user_shell_type out of the old tools::spec module and call it from tools directly - attach the remaining spec planning model tests under spec_plan - delete core/src/tools/spec.rs ## Tests - just fmt - cargo test -p codex-core tools::spec_plan Note: a broader cargo test -p codex-core run on the earlier PR-head worktree still hit the pre-existing stack overflow in agent::control::tests::spawn_agent_fork_last_n_turns_keeps_only_recent_turns. --- codex-rs/core/src/session/review.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 2 +- codex-rs/core/src/tools/mod.rs | 13 ++++++++++++- codex-rs/core/src/tools/spec.rs | 17 ----------------- codex-rs/core/src/tools/spec_plan.rs | 3 +++ .../{spec_tests.rs => spec_plan_model_tests.rs} | 3 +-- 6 files changed, 18 insertions(+), 22 deletions(-) delete mode 100644 codex-rs/core/src/tools/spec.rs rename codex-rs/core/src/tools/{spec_tests.rs => spec_plan_model_tests.rs} (99%) diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index acb4adb945..497cddd912 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -46,7 +46,7 @@ pub(super) async fn spawn_review_thread( .with_image_generation_capability(provider_capabilities.image_generation) .with_web_search_capability(provider_capabilities.web_search) .with_unified_exec_shell_mode_for_session( - crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()), + crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()), sess.services.shell_zsh_path.as_ref(), sess.services.main_execve_wrapper_exe.as_ref(), ) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index cab18b8561..92c7893d5e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -527,7 +527,7 @@ impl Session { .with_image_generation_capability(provider_capabilities.image_generation) .with_web_search_capability(provider_capabilities.web_search) .with_unified_exec_shell_mode_for_session( - crate::tools::spec::tool_user_shell_type(user_shell), + crate::tools::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, ) diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 1effab52c3..5cd943e199 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -11,7 +11,6 @@ pub(crate) mod registry; pub(crate) mod router; pub(crate) mod runtimes; pub(crate) mod sandboxing; -pub(crate) mod spec; pub(crate) mod spec_plan; pub(crate) mod tool_dispatch_trace; pub(crate) mod tool_search_entry; @@ -47,6 +46,18 @@ pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { } } +pub(crate) fn tool_user_shell_type( + user_shell: &crate::shell::Shell, +) -> codex_tools::ToolUserShellType { + match user_shell.shell_type { + crate::shell::ShellType::Zsh => codex_tools::ToolUserShellType::Zsh, + crate::shell::ShellType::Bash => codex_tools::ToolUserShellType::Bash, + crate::shell::ShellType::PowerShell => codex_tools::ToolUserShellType::PowerShell, + crate::shell::ShellType::Sh => codex_tools::ToolUserShellType::Sh, + crate::shell::ShellType::Cmd => codex_tools::ToolUserShellType::Cmd, + } +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model_structured( diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs deleted file mode 100644 index 7ee05c540b..0000000000 --- a/codex-rs/core/src/tools/spec.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::shell::Shell; -use crate::shell::ShellType; -use codex_tools::ToolUserShellType; - -pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { - match user_shell.shell_type { - ShellType::Zsh => ToolUserShellType::Zsh, - ShellType::Bash => ToolUserShellType::Bash, - ShellType::PowerShell => ToolUserShellType::PowerShell, - ShellType::Sh => ToolUserShellType::Sh, - ShellType::Cmd => ToolUserShellType::Cmd, - } -} - -#[cfg(test)] -#[path = "spec_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 679772f4c7..be5e6741d4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -621,6 +621,9 @@ fn code_mode_namespace_name<'a>( .map(|namespace_description| namespace_description.name.as_str()) } +#[cfg(test)] +#[path = "spec_plan_model_tests.rs"] +mod model_tests; #[cfg(test)] #[path = "spec_plan_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs similarity index 99% rename from codex-rs/core/src/tools/spec_tests.rs rename to codex-rs/core/src/tools/spec_plan_model_tests.rs index c74a795793..dcc1be30ed 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -4,6 +4,7 @@ use crate::shell::ShellType; use crate::test_support::construct_model_info_offline; use crate::tools::ToolRouter; use crate::tools::router::ToolRouterParams; +use crate::tools::tool_user_shell_type; use codex_app_server_protocol::AppInfo; use codex_features::Feature; use codex_features::Features; @@ -40,8 +41,6 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; -use super::*; - fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { rmcp::model::Tool { name: name.to_string().into(), From 5d30764fe9d3c1dad4681c16b190050a7e20447a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 15:26:21 +0200 Subject: [PATCH 008/114] Run compact hooks for remote compaction v2 (#22828) ## Why Remote compaction v2 is the `/responses` implementation of session-history compaction, but it still needs to preserve the observable contract of the legacy `/responses/compact` path. In particular, users and integrations that rely on `PreCompact` and `PostCompact` hooks should not see different behavior when `remote_compaction_v2` is enabled. ## What Changed - Runs `PreCompact` before issuing the remote compaction v2 request, including `Interrupted` analytics when a pre-hook stops execution. - Runs `PostCompact` after a successful v2 compaction and aborts the turn if the post-hook stops execution. - Adds `compact_remote_parity` coverage that compares legacy and v2 compaction across manual transcript shapes, automatic pre-turn compaction, automatic mid-turn compaction, hook payloads, replacement history, follow-up request payloads, and API-key `service_tier=fast` behavior. - Registers the new parity suite under `core/tests/suite`. Relevant code: - [`compact_remote_v2.rs`](https://github.com/openai/codex/blob/af63745cb502183a6fc447d0240f8150934d70b7/codex-rs/core/src/compact_remote_v2.rs) - [`compact_remote_parity.rs`](https://github.com/openai/codex/blob/af63745cb502183a6fc447d0240f8150934d70b7/codex-rs/core/tests/suite/compact_remote_parity.rs) ## Verification - Added `core/tests/suite/compact_remote_parity.rs` to assert parity between legacy remote compaction and remote compaction v2 for the affected request, hook, rollout-history, and follow-up paths. - Existing `compact_remote_v2` unit coverage still exercises v2 replacement-history retention and compaction-output collection. --- codex-rs/core/src/compact_remote_v2.rs | 36 +- .../core/tests/suite/compact_remote_parity.rs | 1160 +++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + 3 files changed, 1190 insertions(+), 7 deletions(-) create mode 100644 codex-rs/core/tests/suite/compact_remote_parity.rs diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 9a7a7435f1..3f5368a960 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -12,6 +12,10 @@ 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::trim_function_call_history_to_fit_context_window; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; @@ -97,6 +101,21 @@ async fn run_remote_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track( + sess.as_ref(), + codex_analytics::CompactionStatus::Interrupted, + Some(error), + ) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_remote_compact_task_inner_impl( sess, turn_context, @@ -104,13 +123,16 @@ async fn run_remote_compact_task_inner( initial_context_injection, ) .await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs new file mode 100644 index 0000000000..96616fa9cb --- /dev/null +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -0,0 +1,1160 @@ +#![allow(clippy::expect_used)] + +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Result; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::user_input::UserInput; +use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses; +use core_test_support::responses::ResponseMock; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; +const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Mode { + Legacy, + V2, +} + +#[derive(Clone, Copy, Debug)] +enum AuthCase { + ChatGpt, + ApiKey, +} + +impl AuthCase { + fn build(self) -> CodexAuth { + match self { + AuthCase::ChatGpt => CodexAuth::create_dummy_chatgpt_auth_for_testing(), + AuthCase::ApiKey => CodexAuth::from_api_key("dummy"), + } + } +} + +#[derive(Clone, Copy, Debug)] +struct RunSettings { + auth: AuthCase, + service_tier_fast: bool, +} + +impl Default for RunSettings { + fn default() -> Self { + Self { + auth: AuthCase::ChatGpt, + service_tier_fast: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +enum Step { + Assistant, + ReasoningAssistant, + FunctionTool, + ShellTool, + ImageAssistant, + WebSearchAssistant, +} + +impl Step { + fn label(self) -> &'static str { + match self { + Step::Assistant => "assistant", + Step::ReasoningAssistant => "reasoning_assistant", + Step::FunctionTool => "function_tool", + Step::ShellTool => "shell_tool", + Step::ImageAssistant => "image_assistant", + Step::WebSearchAssistant => "web_search_assistant", + } + } +} + +#[derive(Debug)] +struct Scenario { + name: &'static str, + steps: &'static [Step], +} + +#[derive(Debug)] +struct Capture { + compact_body: Value, + follow_up_body: Value, + replacement_history: Value, + normal_response_requests: usize, + compact_requests: usize, +} + +const ASSISTANT_ONLY: &[Step] = &[Step::Assistant]; +const REASONING_IMAGE: &[Step] = &[Step::ReasoningAssistant, Step::ImageAssistant]; +const TOOL_MIX: &[Step] = &[Step::Assistant, Step::FunctionTool, Step::ShellTool]; +const FULL_MIX: &[Step] = &[ + Step::ReasoningAssistant, + Step::FunctionTool, + Step::ImageAssistant, + Step::ShellTool, + Step::WebSearchAssistant, + Step::Assistant, +]; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_manual_transcripts() -> Result<()> { + skip_if_no_network!(Ok(())); + + let scenarios = [ + Scenario { + name: "assistant_only", + steps: ASSISTANT_ONLY, + }, + Scenario { + name: "reasoning_image", + steps: REASONING_IMAGE, + }, + Scenario { + name: "tool_mix", + steps: TOOL_MIX, + }, + Scenario { + name: "full_mix", + steps: FULL_MIX, + }, + ]; + + for scenario in scenarios { + compare_manual_scenario(&scenario, RunSettings::default()).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_v2_api_key_sends_service_tier_upgrade() -> Result<()> { + skip_if_no_network!(Ok(())); + + let scenario = Scenario { + name: "api_key_service_tier", + steps: TOOL_MIX, + }; + let settings = RunSettings { + auth: AuthCase::ApiKey, + service_tier_fast: true, + }; + let legacy = run_manual_session(&scenario, Mode::Legacy, settings).await?; + let v2 = run_manual_session(&scenario, Mode::V2, settings).await?; + + assert_eq!( + legacy.compact_body.get("service_tier"), + None, + "legacy /responses/compact should continue omitting service_tier for API-key auth" + ); + assert_eq!( + v2.compact_body.get("service_tier").and_then(Value::as_str), + Some(ServiceTier::Fast.request_value()), + "v2 compaction should send service_tier through /responses for API-key auth" + ); + + assert_compact_requests_eq_except_v2_service_tier("api-key service tier", &legacy, &v2); + assert_follow_up_and_history_eq("api-key service tier", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_manual_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_manual_hook_session(Mode::Legacy).await?; + let v2 = run_manual_hook_session(Mode::V2).await?; + assert_json_eq("manual compact hook payload parity mismatch", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_pre_turn_auto() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_pre_turn_auto_session(Mode::Legacy).await?; + let v2 = run_pre_turn_auto_session(Mode::V2).await?; + assert_capture_eq("pre-turn auto", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_mid_turn_auto() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_mid_turn_auto_session(Mode::Legacy).await?; + let v2 = run_mid_turn_auto_session(Mode::V2).await?; + assert_capture_eq("mid-turn auto", &legacy, &v2); + Ok(()) +} + +async fn compare_manual_scenario(scenario: &Scenario, settings: RunSettings) -> Result<()> { + let legacy = run_manual_session(scenario, Mode::Legacy, settings).await?; + let v2 = run_manual_session(scenario, Mode::V2, settings).await?; + assert_capture_eq(scenario.name, &legacy, &v2); + Ok(()) +} + +fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { + assert_eq!( + legacy.compact_requests, 1, + "legacy compact endpoint should be called exactly once for {label}", + ); + assert_eq!( + v2.compact_requests, 0, + "v2 should not call /responses/compact for {label}", + ); + + let legacy_compact = compact_request_view(&legacy.compact_body, Mode::Legacy); + let v2_compact = compact_request_view(&v2.compact_body, Mode::V2); + assert_json_eq( + &format!("compact request parity mismatch for {label}"), + &legacy_compact, + &v2_compact, + ); + + let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); + let v2_follow_up = follow_up_request_view(&v2.follow_up_body); + assert_json_eq( + &format!("post-compact follow-up request parity mismatch for {label}"), + &legacy_follow_up, + &v2_follow_up, + ); + + assert_json_eq( + &format!("replacement history parity mismatch for {label}"), + &legacy.replacement_history, + &v2.replacement_history, + ); + + println!( + "PARITY_OK scenario={} normal_response_requests={} compact_input_items={} replacement_history_items={} follow_up_input_items={}", + label, + legacy.normal_response_requests, + compact_input_len(&legacy.compact_body, Mode::Legacy), + replacement_history_len(&legacy.replacement_history), + follow_up_input_len(&legacy.follow_up_body) + ); +} + +fn assert_compact_requests_eq_except_v2_service_tier(label: &str, legacy: &Capture, v2: &Capture) { + assert_eq!( + legacy.compact_requests, 1, + "legacy compact endpoint should be called exactly once for {label}", + ); + assert_eq!( + v2.compact_requests, 0, + "v2 should not call /responses/compact for {label}", + ); + + let legacy_compact = compact_request_view(&legacy.compact_body, Mode::Legacy); + let mut v2_compact = compact_request_view(&v2.compact_body, Mode::V2); + remove_object_field(&mut v2_compact, "service_tier"); + assert_json_eq( + &format!("compact request parity mismatch for {label} after service_tier upgrade"), + &legacy_compact, + &v2_compact, + ); +} + +fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) { + let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); + let v2_follow_up = follow_up_request_view(&v2.follow_up_body); + assert_json_eq( + &format!("post-compact follow-up request parity mismatch for {label}"), + &legacy_follow_up, + &v2_follow_up, + ); + + assert_json_eq( + &format!("replacement history parity mismatch for {label}"), + &legacy.replacement_history, + &v2.replacement_history, + ); +} + +async fn run_manual_session( + scenario: &Scenario, + mode: Mode, + settings: RunSettings, +) -> Result { + let mut response_bodies = response_bodies_for_scenario(scenario); + if mode == Mode::V2 { + response_bodies.push(compaction_v2_response_body()); + } + response_bodies.push(after_compact_response_body(scenario.name)); + + let harness = build_harness(mode, settings, /*hooks*/ false).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + for (idx, step) in scenario.steps.iter().enumerate() { + submit_user_input(&codex, user_input_for_step(scenario.name, idx, *step)).await?; + } + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: format!("{}_AFTER_COMPACT_USER", scenario.name), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_pre_turn_auto_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![ + responses::sse(vec![ + responses::ev_assistant_message("pre-turn-first-message", "PRE_TURN_FIRST_REPLY"), + responses::ev_completed_with_tokens( + "pre-turn-first-response", + /*total_tokens*/ 500, + ), + ]), + after_compact_response_body("pre_turn_auto"), + ], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_assistant_message("pre-turn-first-message", "PRE_TURN_FIRST_REPLY"), + responses::ev_completed_with_tokens( + "pre-turn-first-response", + /*total_tokens*/ 500, + ), + ]), + compaction_v2_response_body(), + after_compact_response_body("pre_turn_auto"), + ], + }; + let harness = build_auto_harness(mode).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "pre_turn_auto_before".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + submit_user_input( + &codex, + vec![UserInput::Text { + text: "pre_turn_auto_after".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_mid_turn_auto_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![ + responses::sse(vec![ + responses::ev_function_call("mid-turn-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens( + "mid-turn-call-response", + /*total_tokens*/ 500, + ), + ]), + after_compact_response_body("mid_turn_auto"), + ], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_function_call("mid-turn-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens( + "mid-turn-call-response", + /*total_tokens*/ 500, + ), + ]), + compaction_v2_response_body(), + after_compact_response_body("mid_turn_auto"), + ], + }; + let harness = build_auto_harness(mode).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "mid_turn_auto_user".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_manual_hook_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![responses::sse(vec![ + responses::ev_assistant_message("hook-first-message", "HOOK_FIRST_REPLY"), + responses::ev_completed("hook-first-response"), + ])], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_assistant_message("hook-first-message", "HOOK_FIRST_REPLY"), + responses::ev_completed("hook-first-response"), + ]), + compaction_v2_response_body(), + ], + }; + let harness = build_harness(mode, RunSettings::default(), /*hooks*/ true).await?; + let codex = harness.test().codex.clone(); + responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "manual_hooks_before".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + if let Some(compact_mock) = compact_mock { + assert_eq!(compact_mock.requests().len(), 1); + } + + let home = harness.test().codex_home_path(); + let pre = hook_log_view(&home.join("pre_compact_manual_log.jsonl"))?; + let post = hook_log_view(&home.join("post_compact_manual_log.jsonl"))?; + Ok(json!({ + "pre": pre, + "post": post, + })) +} + +async fn build_auto_harness(mode: Mode) -> Result { + build_harness_inner( + mode, + RunSettings::default(), + /*hooks*/ false, + Some(200), + ) + .await +} + +async fn build_harness(mode: Mode, settings: RunSettings, hooks: bool) -> Result { + build_harness_inner(mode, settings, hooks, /*auto_compact_limit*/ None).await +} + +async fn build_harness_inner( + mode: Mode, + settings: RunSettings, + hooks: bool, + auto_compact_limit: Option, +) -> Result { + fs::create_dir_all(FIXED_CWD)?; + let mut builder = test_codex().with_auth(settings.auth.build()); + if hooks { + builder = builder.with_pre_build_hook(write_manual_compact_hooks); + } + TestCodexHarness::with_builder(builder.with_config(move |config| { + config.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(PathBuf::from( + FIXED_CWD, + )) + .expect("fixed cwd should be absolute"); + config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); + if settings.service_tier_fast { + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + } + config.model_auto_compact_token_limit = auto_compact_limit; + if hooks { + trust_discovered_hooks(config); + } + if mode == Mode::V2 { + let _ = config.features.enable(Feature::RemoteCompactionV2); + } + })) + .await +} + +fn rollout_path(harness: &TestCodexHarness) -> PathBuf { + harness + .test() + .session_configured + .rollout_path + .clone() + .expect("rollout path") +} + +async fn mount_legacy_compact_if_needed( + harness: &TestCodexHarness, + mode: Mode, +) -> Option { + match mode { + Mode::Legacy => Some( + responses::mount_compact_user_history_with_summary_once(harness.server(), SUMMARY) + .await, + ), + Mode::V2 => None, + } +} + +fn follow_up_index(request_count: usize) -> usize { + request_count.checked_sub(1).expect("follow-up request") +} + +async fn capture_from_requests( + mode: Mode, + codex: &codex_core::CodexThread, + rollout_path: &Path, + responses_mock: &ResponseMock, + compact_mock: Option<&ResponseMock>, + follow_up_index: usize, +) -> Result { + let response_requests = responses_mock.requests(); + let follow_up_body = response_requests + .get(follow_up_index) + .expect("follow-up request should be present") + .body_json(); + + let (compact_body, compact_requests) = match (mode, compact_mock) { + (Mode::Legacy, Some(compact_mock)) => { + let compact_requests = compact_mock.requests().len(); + (compact_mock.single_request().body_json(), compact_requests) + } + (Mode::V2, None) => { + let compact_index = follow_up_index + .checked_sub(1) + .expect("v2 compact request should precede follow-up"); + (response_requests[compact_index].body_json(), 0) + } + (Mode::Legacy, None) | (Mode::V2, Some(_)) => panic!("unexpected compact mock state"), + }; + + codex.submit(Op::Shutdown).await?; + wait_for_event(codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + Ok(Capture { + compact_body, + follow_up_body, + replacement_history: replacement_history_from_rollout(rollout_path)?, + normal_response_requests: response_requests.len(), + compact_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, + }) + .await?; + wait_for_turn_complete(codex).await; + Ok(()) +} + +async fn wait_for_turn_complete(codex: &codex_core::CodexThread) { + wait_for_event(codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; +} + +fn user_input_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec { + let mut items = Vec::new(); + if matches!(step, Step::ImageAssistant) { + items.push(UserInput::Image { + image_url: IMAGE_URL.to_string(), + }); + } + items.push(UserInput::Text { + text: format!("{}_USER_TURN_{}_{}", scenario_name, idx, step.label()), + text_elements: Vec::new(), + }); + items +} + +fn response_bodies_for_scenario(scenario: &Scenario) -> Vec { + scenario + .steps + .iter() + .enumerate() + .flat_map(|(idx, step)| response_bodies_for_step(scenario.name, idx, *step)) + .collect() +} + +fn response_bodies_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec { + let response_id = format!("{scenario_name}-{idx}-{}", step.label()); + match step { + Step::Assistant => vec![responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} assistant reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::ReasoningAssistant => vec![responses::sse(vec![ + responses::ev_reasoning_item( + &format!("{response_id}-reasoning"), + &["PARITY_REASONING_SUMMARY"], + &["parity raw reasoning content"], + ), + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} reasoning reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::FunctionTool => vec![ + responses::sse(vec![ + responses::ev_function_call( + &format!("{response_id}-call"), + DUMMY_FUNCTION_NAME, + r#"{"case":"parity"}"#, + ), + responses::ev_completed(&format!("{response_id}-tool-response")), + ]), + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-final-message"), + &format!("{scenario_name} function follow-up {idx}"), + ), + responses::ev_completed(&format!("{response_id}-final-response")), + ]), + ], + Step::ShellTool => vec![ + responses::sse(vec![ + responses::ev_shell_command_call( + &format!("{response_id}-shell-call"), + &format!("echo {scenario_name}_{idx}_SHELL_TOOL"), + ), + responses::ev_completed(&format!("{response_id}-shell-response")), + ]), + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-final-message"), + &format!("{scenario_name} shell follow-up {idx}"), + ), + responses::ev_completed(&format!("{response_id}-final-response")), + ]), + ], + Step::ImageAssistant => vec![responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} image reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::WebSearchAssistant => vec![responses::sse(vec![ + responses::ev_web_search_call_done( + &format!("{response_id}-web-search"), + "completed", + &format!("{scenario_name} parity query"), + ), + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} web search reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + } +} + +fn compaction_v2_response_body() -> String { + responses::sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": SUMMARY, + } + }), + responses::ev_completed("remote-compaction-v2-response"), + ]) +} + +fn after_compact_response_body(scenario_name: &str) -> String { + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{scenario_name}-after-compact-message"), + &format!("{scenario_name} after compact reply"), + ), + responses::ev_completed(&format!("{scenario_name}-after-compact-response")), + ]) +} + +fn compact_request_view(body: &Value, mode: Mode) -> Value { + let mut input = body + .get("input") + .and_then(Value::as_array) + .cloned() + .expect("compact request should include input"); + if mode == Mode::V2 { + let trigger = input + .pop() + .expect("v2 compact input should end with trigger"); + assert_eq!( + trigger, + json!({"type": "compaction_trigger"}), + "v2 compact input should append exactly one compaction_trigger" + ); + } + + let mut selected = selected_request_fields(body, SelectedFieldsMode::Compact); + selected["input"] = normalize_value(Value::Array(input)); + canonical_json(&normalize_value(selected)) +} + +fn follow_up_request_view(body: &Value) -> Value { + let mut selected = selected_request_fields(body, SelectedFieldsMode::FollowUp); + selected["input"] = normalize_value( + body.get("input") + .cloned() + .expect("follow-up request should include input"), + ); + canonical_json(&normalize_value(selected)) +} + +fn replacement_history_from_rollout(path: &Path) -> Result { + let rollout_text = fs::read_to_string(path)?; + let mut replacement_history = None; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if let RolloutItem::Compacted(compacted) = entry.item + && compacted.message.is_empty() + && let Some(items) = compacted.replacement_history + { + let values = items + .into_iter() + .map(|item| serde_json::to_value(item).expect("serialize replacement item")) + .collect::>(); + replacement_history = Some(Value::Array(values)); + } + } + let replacement_history = + replacement_history.expect("expected compacted rollout replacement history"); + Ok(canonical_json(&normalize_value(replacement_history))) +} + +fn write_manual_compact_hooks(home: &Path) { + write_hook_script( + &home.join("pre_compact_manual.py"), + &home.join("pre_compact_manual_log.jsonl"), + ); + write_hook_script( + &home.join("post_compact_manual.py"), + &home.join("post_compact_manual_log.jsonl"), + ); + let hooks = json!({ + "hooks": { + "PreCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&home.join("pre_compact_manual.py")), + }] + }], + "PostCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&home.join("post_compact_manual.py")), + }] + }] + } + }); + fs::write(home.join("hooks.json"), hooks.to_string()).expect("write hooks.json"); +} + +fn write_hook_script(script_path: &Path, log_path: &Path) { + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") +"#, + log_path = log_path.display(), + ); + fs::write(script_path, script).expect("write compact hook script"); +} + +fn python_hook_command(script_path: &Path) -> String { + format!("python3 \"{}\"", script_path.display()) +} + +fn hook_log_view(path: &Path) -> Result { + let text = fs::read_to_string(path)?; + let values = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let payload: Value = serde_json::from_str(line).expect("parse compact hook payload"); + json!({ + "hook_event_name": payload["hook_event_name"].clone(), + "trigger": payload["trigger"].clone(), + "model": payload["model"].clone(), + "has_reason": payload.get("reason").is_some(), + "has_phase": payload.get("phase").is_some(), + "has_implementation": payload.get("implementation").is_some(), + "has_status": payload.get("status").is_some(), + "has_error": payload.get("error").is_some(), + }) + }) + .collect::>(); + Ok(Value::Array(values)) +} + +#[derive(Clone, Copy)] +enum SelectedFieldsMode { + Compact, + FollowUp, +} + +fn selected_request_fields(body: &Value, mode: SelectedFieldsMode) -> Value { + let mut selected = serde_json::Map::new(); + let fields: &[&str] = match mode { + SelectedFieldsMode::Compact => &[ + "model", + "instructions", + "parallel_tool_calls", + "reasoning", + "service_tier", + "prompt_cache_key", + "text", + "tools", + "previous_response_id", + ], + SelectedFieldsMode::FollowUp => &[ + "model", + "instructions", + "parallel_tool_calls", + "reasoning", + "service_tier", + "prompt_cache_key", + "text", + "tools", + "tool_choice", + "previous_response_id", + "store", + "stream", + "include", + ], + }; + for field in fields { + if let Some(value) = body.get(field) { + selected.insert((*field).to_string(), normalize_value(value.clone())); + } + } + Value::Object(selected) +} + +fn normalize_value(value: Value) -> Value { + match value { + Value::String(text) => Value::String(normalize_string(&text)), + Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (key, normalize_value(value))) + .collect(), + ), + Value::Null | Value::Bool(_) | Value::Number(_) => value, + } +} + +fn normalize_string(value: &str) -> String { + if is_uuid_like(value) { + return "".to_string(); + } + + let mut text = value.to_string(); + normalize_tmp_prefix_before_marker(&mut text, "/skills/"); + normalize_tmp_prefix_before_marker(&mut text, "\\skills\\"); + + let mut search_start = 0; + let wall_time_prefix = "Wall time: "; + let wall_time_suffix = " seconds"; + while let Some(relative_start) = text[search_start..].find(wall_time_prefix) { + let value_start = search_start + relative_start + wall_time_prefix.len(); + let Some(relative_end) = text[value_start..].find(wall_time_suffix) else { + break; + }; + let value_end = value_start + relative_end; + let value = &text[value_start..value_end]; + if !value.is_empty() + && value.chars().any(|ch| ch.is_ascii_digit()) + && value.chars().all(|ch| ch.is_ascii_digit() || ch == '.') + { + text.replace_range(value_start..value_end, ""); + search_start = value_start + "".len() + wall_time_suffix.len(); + } else { + search_start = value_end + wall_time_suffix.len(); + } + } + text +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8, 13, 18, 23].iter().all(|idx| bytes[*idx] == b'-') + && bytes + .iter() + .enumerate() + .all(|(idx, byte)| [8, 13, 18, 23].contains(&idx) || byte.is_ascii_hexdigit()) +} + +fn normalize_tmp_prefix_before_marker(text: &mut String, marker: &str) { + let mut search_start = 0; + while let Some(relative_marker_index) = text[search_start..].find(marker) { + let marker_index = search_start + relative_marker_index; + let prefix = &text[..marker_index]; + let windows_appdata_temp_start = prefix + .rfind("/AppData/Local/Temp/.tmp") + .and_then(|temp_index| prefix[..temp_index].rfind(":/Users/")) + .and_then(|colon_index| colon_index.checked_sub(1)) + .or_else(|| { + prefix + .rfind("\\AppData\\Local\\Temp\\.tmp") + .and_then(|temp_index| prefix[..temp_index].rfind(":\\Users\\")) + .and_then(|colon_index| colon_index.checked_sub(1)) + }); + let start = prefix + .rfind("/private/var/folders/") + .or_else(|| prefix.rfind("/var/folders/")) + .or_else(|| prefix.rfind("/private/tmp/.tmp")) + .or_else(|| prefix.rfind("/tmp/.tmp")) + .or(windows_appdata_temp_start); + if let Some(start_index) = start { + text.replace_range(start_index..marker_index, ""); + search_start = start_index + "".len() + marker.len(); + } else { + search_start = marker_index + marker.len(); + } + } +} + +#[test] +fn normalize_string_rewrites_linux_temp_skill_paths() { + let text = normalize_string( + "file: /tmp/.tmp5YYdK3/skills/.system/imagegen/SKILL.md and \ + /private/tmp/.tmpw3wqF9/skills/custom/SKILL.md", + ); + + assert_eq!( + text, + "file: /skills/.system/imagegen/SKILL.md and \ + /skills/custom/SKILL.md" + ); +} + +#[test] +fn normalize_string_rewrites_windows_temp_skill_paths() { + let text = normalize_string( + "file: C:/Users/runneradmin/AppData/Local/Temp/.tmpDuYxa3/skills/.system/imagegen/SKILL.md and \ + C:\\Users\\runneradmin\\AppData\\Local\\Temp\\.tmpiP36Yr\\skills\\custom\\SKILL.md", + ); + + assert_eq!( + text, + "file: /skills/.system/imagegen/SKILL.md and \ + \\skills\\custom\\SKILL.md" + ); +} + +#[test] +fn normalize_string_rewrites_shell_wall_times() { + let text = normalize_string( + "Exit code: 0\nWall time: 0 seconds\nOutput:\nok\n\ + Exit code: 0\nWall time: 0.1 seconds\nOutput:\nok", + ); + + assert_eq!( + text, + "Exit code: 0\nWall time: seconds\nOutput:\nok\n\ + Exit code: 0\nWall time: seconds\nOutput:\nok" + ); +} + +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(), + } +} + +fn remove_object_field(value: &mut Value, field: &str) { + if let Value::Object(map) = value { + map.remove(field); + } +} + +fn assert_json_eq(label: &str, left: &Value, right: &Value) { + if left != right { + panic!("{}\n{}", label, first_json_diff(left, right, "$")); + } +} + +fn first_json_diff(left: &Value, right: &Value, path: &str) -> String { + match (left, right) { + (Value::Object(left_map), Value::Object(right_map)) => { + let mut keys = left_map.keys().chain(right_map.keys()).collect::>(); + keys.sort(); + keys.dedup(); + for key in keys { + match (left_map.get(key), right_map.get(key)) { + (Some(left_value), Some(right_value)) if left_value != right_value => { + return first_json_diff(left_value, right_value, &format!("{path}.{key}")); + } + (None, Some(right_value)) => { + return format!( + "{path}.{key}: missing on left, right={}", + short_json(right_value) + ); + } + (Some(left_value), None) => { + return format!( + "{path}.{key}: left={}, missing on right", + short_json(left_value) + ); + } + (Some(_), Some(_)) | (None, None) => {} + } + } + format!("{path}: object mismatch") + } + (Value::Array(left_values), Value::Array(right_values)) => { + let len = left_values.len().min(right_values.len()); + for idx in 0..len { + if left_values[idx] != right_values[idx] { + return first_json_diff( + &left_values[idx], + &right_values[idx], + &format!("{path}[{idx}]"), + ); + } + } + if left_values.len() != right_values.len() { + return format!( + "{path}: array len left={} right={}", + left_values.len(), + right_values.len() + ); + } + format!("{path}: array mismatch") + } + _ => format!( + "{path}: left={} right={}", + short_json(left), + short_json(right) + ), + } +} + +fn short_json(value: &Value) -> String { + let text = serde_json::to_string(value).expect("serialize short json value"); + const MAX_LEN: usize = 1000; + if text.len() <= MAX_LEN { + text + } else { + let prefix = text.chars().take(MAX_LEN).collect::(); + format!("{prefix}...<{} chars>", text.len()) + } +} + +fn compact_input_len(body: &Value, mode: Mode) -> usize { + let len = body + .get("input") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default(); + match mode { + Mode::Legacy => len, + Mode::V2 => len.saturating_sub(1), + } +} + +fn follow_up_input_len(body: &Value) -> usize { + body.get("input") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default() +} + +fn replacement_history_len(body: &Value) -> usize { + body.as_array().map(Vec::len).unwrap_or_default() +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0d89516d3d..87772aa279 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -43,6 +43,7 @@ mod codex_delegate; mod collaboration_instructions; mod compact; mod compact_remote; +mod compact_remote_parity; mod compact_resume_fork; mod deprecation_notice; mod exec; From cccde930ce575eec18f8ecfa4838f8e155ab771e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 15 May 2026 16:19:34 +0200 Subject: [PATCH 009/114] Move memory prompt injection to app-server extension (#22841) ## Why Memory prompt injection should be owned by the extension path that app-server composes at runtime, not by an inlined special case inside `codex-core`. This keeps `codex-core` focused on session orchestration while allowing the memories extension to own its app-server prompt behavior. ## What Changed - Registers `codex-memories-extension` in the app-server extension registry. - Moves the memory developer-instruction injection out of `core/src/session/mod.rs` and into the memories extension prompt contributor. - Adds config-change handling so the extension keeps its per-thread memory settings in sync after startup. - Leaves memories read/retrieval tools unregistered for now so this PR only changes prompt injection. - Removes the stale `cargo-shear` ignore now that app-server depends on the extension crate. ## Validation Not run locally; validation is left to CI. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 - codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/extensions.rs | 1 + codex-rs/core/src/session/mod.rs | 10 ------- codex-rs/ext/memories/src/extension.rs | 36 +++++++++++++++++++++----- 6 files changed, 32 insertions(+), 18 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 50de8dc946..eb2439401b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1907,6 +1907,7 @@ dependencies = [ "codex-hooks", "codex-login", "codex-mcp", + "codex-memories-extension", "codex-memories-write", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 14b1264a81..7207fd3aba 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -470,7 +470,6 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-memories-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c75461ecf7..95baac4e9e 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -54,6 +54,7 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-memories-extension = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index a293daf4f2..689eae2e9d 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -18,6 +18,7 @@ where { let mut builder = ExtensionRegistryBuilder::::new(); codex_guardian::install(&mut builder, guardian_agent_spawner); + codex_memories_extension::install(&mut builder); Arc::new(builder.build()) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 142e39ea97..2dd97710e8 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2690,14 +2690,6 @@ impl Session { { developer_sections.push(developer_instructions.to_string()); } - // Add developer instructions for memories. - if turn_context.features.enabled(Feature::MemoryTool) - && turn_context.config.memories.use_memories - && let Some(memory_prompt) = - build_memory_tool_developer_instructions(&turn_context.config.codex_home).await - { - developer_sections.push(memory_prompt); - } // Add developer instructions from collaboration_mode if they exist and are non-empty if turn_context.config.include_collaboration_mode_instructions && let Some(collab_instructions) = @@ -3438,8 +3430,6 @@ pub(crate) fn emit_subagent_session_started( }); } -use codex_memories_read::build_memory_tool_developer_instructions; - /// Builds the hook engine for one config snapshot, including any enabled plugin hooks. async fn build_hooks_for_config( config: &Config, diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 9226ddfdcc..1a52eb2cb9 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_extension_api::ConfigContributor; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; @@ -25,6 +26,15 @@ pub(crate) struct MemoriesExtensionConfig { pub(crate) codex_home: AbsolutePathBuf, } +impl MemoriesExtensionConfig { + fn from_config(config: &Config) -> Self { + Self { + enabled: config.features.enabled(Feature::MemoryTool) && config.memories.use_memories, + codex_home: config.codex_home.clone(), + } + } +} + impl ContextContributor for MemoriesExtension { fn contribute<'a>( &'a self, @@ -50,11 +60,21 @@ impl ContextContributor for MemoriesExtension { impl ThreadLifecycleContributor for MemoriesExtension { fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { - input.thread_store.insert(MemoriesExtensionConfig { - enabled: input.config.features.enabled(Feature::MemoryTool) - && input.config.memories.use_memories, - codex_home: input.config.codex_home.clone(), - }); + input + .thread_store + .insert(MemoriesExtensionConfig::from_config(input.config)); + } +} + +impl ConfigContributor for MemoriesExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + thread_store.insert(MemoriesExtensionConfig::from_config(new_config)); } } @@ -79,6 +99,8 @@ impl ToolContributor for MemoriesExtension { pub fn install(registry: &mut ExtensionRegistryBuilder) { let extension = Arc::new(MemoriesExtension); registry.thread_lifecycle_contributor(extension.clone()); - registry.prompt_contributor(extension.clone()); - registry.tool_contributor(extension); + registry.config_contributor(extension.clone()); + registry.prompt_contributor(extension); + // Keep the read/retrieval tools out of app-server until that rollout is intentional. + // registry.tool_contributor(extension); } From 68ccfdc9056f7305b96a9c33a953bc9177c47a08 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 08:59:31 -0700 Subject: [PATCH 010/114] guardian: use permission profile for review sandbox (#22789) ## Why `SandboxPolicy` is being pushed back toward legacy config loading and compatibility boundaries. Guardian review sessions already want the built-in read-only permission behavior; carrying that as an active `PermissionProfile` makes the review sandbox follow the new permissions path instead of configuring the child session through the legacy policy API. ## What Changed - Configure the guardian review session with `PermissionProfile::read_only()`. - Send the read-only profile through the guardian child `Op::UserTurn`. - Keep the legacy `sandbox_policy` field populated with `SandboxPolicy::new_read_only_policy()` declared next to the profile so the two remain visibly in sync until the compatibility field goes away. ## How To Review Start in `codex-rs/core/src/guardian/review_session.rs`. The important check is that both the guardian config and the child turn now use the read-only permission profile, while the remaining `SandboxPolicy::ReadOnly` assignment is only the compatibility field required by the current turn protocol. ## Verification - `cargo test -p codex-core guardian_review_session_config_clears_parent_developer_instructions` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22789). * #22795 * #22792 * #22791 * #22790 * __->__ #22789 --- codex-rs/core/src/guardian/review_session.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2f15548919..afb5882a69 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -9,6 +9,7 @@ use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; @@ -698,6 +699,9 @@ async fn run_review_on_session( .total_token_usage() .await .unwrap_or_default(); + // The legacy SandboxPolicy should match the PermissionProfile. + let guardian_permission_profile = PermissionProfile::read_only(); + let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy(); let submit_result = run_before_review_deadline( deadline, @@ -709,8 +713,8 @@ async fn run_review_on_session( cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy: legacy_sandbox_policy, + permission_profile: Some(guardian_permission_profile), model: params.model.clone(), effort: params.reasoning_effort, summary: Some(params.reasoning_summary), @@ -892,12 +896,11 @@ pub(crate) fn build_guardian_review_session_config( ); guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); guardian_config .permissions - .set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path()) + .set_permission_profile(PermissionProfile::read_only()) .map_err(|err| { - anyhow::anyhow!("guardian review session could not set sandbox policy: {err}") + anyhow::anyhow!("guardian review session could not set permission profile: {err}") })?; guardian_config.include_apps_instructions = false; guardian_config From 7fa0007ea878b08194529cb55a9785e01481f00e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 15 May 2026 09:12:52 -0700 Subject: [PATCH 011/114] tui: split remaining composer draft and footer state (#22656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why [#22581](https://github.com/openai/codex/pull/22581) started separating the chat composer’s responsibilities, but `ChatComposer` still owned the remaining editable draft state alongside footer/status presentation state. This follow-up makes those ownership lines explicit so future composer changes have a smaller blast radius and `BottomPane` does not need to keep exposing scattered draft getters. This is just a refactor. No functional or behavioral changes are intended. ## What changed - Move the remaining editable composer state into `bottom_pane/chat_composer/draft_state.rs`. - Move footer and status-row presentation state into `bottom_pane/chat_composer/footer_state.rs`. - Add an internal `ComposerDraftSnapshot` for restore flows, replacing several ad hoc `BottomPane` pass-through reads. - Rewire the related history-search and thread-input restore paths to use the extracted state. ## Verification - `RUST_MIN_STACK=8388608 cargo test -p codex-tui` - `cargo insta pending-snapshots` --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1533 +++++++++-------- .../bottom_pane/chat_composer/draft_state.rs | 45 + .../bottom_pane/chat_composer/footer_state.rs | 73 + .../chat_composer/history_search.rs | 80 +- codex-rs/tui/src/bottom_pane/mod.rs | 11 +- codex-rs/tui/src/chatwidget/input_restore.rs | 24 +- 6 files changed, 1006 insertions(+), 760 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4bfd5da17c..e76b4c0b96 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -218,10 +218,15 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; mod attachment_state; +mod draft_state; +mod footer_state; mod history_search; mod popup_state; use self::attachment_state::AttachmentState; +use self::draft_state::ComposerMentionBinding; +use self::draft_state::DraftState; +use self::footer_state::FooterState; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; use self::popup_state::PopupState; @@ -231,7 +236,6 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; @@ -246,7 +250,6 @@ use codex_file_search::FileMatch; #[cfg(test)] use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; -use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -342,57 +345,29 @@ impl ChatComposerConfig { } pub(crate) struct ChatComposer { - textarea: TextArea, - textarea_state: RefCell, - is_bash_mode: bool, + draft: DraftState, popups: PopupState, app_event_tx: AppEventSender, history: ChatComposerHistory, - quit_shortcut_expires_at: Option, - quit_shortcut_key: KeyBinding, - esc_backtrack_hint: bool, - use_shift_enter_hint: bool, - pending_pastes: Vec<(String, String)>, + footer: FooterState, has_focus: bool, frame_requester: Option, attachments: AttachmentState, placeholder_text: String, is_task_running: bool, - /// When false, the composer is temporarily read-only (e.g. during sandbox setup). - input_enabled: bool, - input_disabled_placeholder: Option, - /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). - paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. - disable_paste_burst: bool, - footer_mode: FooterMode, - footer_hint_override: Option>, - /// Whether the ambient footer row is currently replaced by the Plan-mode nudge. - /// - /// Eligibility is decided by `ChatWidget`; the composer only owns presentation so enabling - /// the nudge never changes layout height or reimplements mode-selection policy here. - plan_mode_nudge_visible: bool, /// Slash-command draft staged for local recall after application-level dispatch. /// /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can /// prepare their argument text without also double-recording the full command invocation. pending_slash_command_history: Option, - footer_flash: Option, - context_window_percent: Option, // Monotonically increasing identifier for textarea elements we insert. #[cfg(not(target_os = "linux"))] next_element_id: u64, - context_window_used_tokens: Option, skills: Option>, plugins: Option>, connectors_snapshot: Option, - mention_bindings: HashMap, - recent_submission_mention_bindings: Vec, collaboration_modes_enabled: bool, config: ChatComposerConfig, - collaboration_mode_indicator: Option, - goal_status_indicator: Option, - ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, service_tier_commands_enabled: bool, @@ -404,12 +379,6 @@ pub(crate) struct ChatComposer { audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, side_conversation_active: bool, - status_line_value: Option>, - status_line_hyperlink_url: Option, - status_line_enabled: bool, - side_conversation_context_label: Option, - // Agent label injected into the footer's contextual row when multi-agent mode is active. - active_agent_label: Option, history_search: Option, submit_keys: Vec, queue_keys: Vec, @@ -418,20 +387,6 @@ pub(crate) struct ChatComposer { history_search_next_keys: Vec, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, - footer_external_editor_key: Option, - footer_show_transcript_key: Option, - footer_insert_newline_key: Option, - footer_queue_key: Option, - footer_toggle_shortcuts_key: Option, - footer_history_search_key: Option, - footer_reasoning_down_key: Option, - footer_reasoning_up_key: Option, -} - -#[derive(Clone, Debug)] -struct FooterFlash { - line: Line<'static>, - expires_at: Instant, } #[derive(Clone, Debug)] @@ -446,9 +401,13 @@ struct ComposerDraft { } #[derive(Clone, Debug)] -struct ComposerMentionBinding { - mention: String, - path: String, +pub(crate) struct ComposerDraftSnapshot { + pub(crate) text: String, + pub(crate) text_elements: Vec, + pub(crate) local_images: Vec, + pub(crate) remote_image_urls: Vec, + pub(crate) mention_bindings: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -523,45 +482,56 @@ impl ChatComposer { let default_vim_normal_keymap = default_keymap.vim_normal.clone(); let mut this = Self { - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - is_bash_mode: false, + draft: DraftState::new(), popups: PopupState::default(), app_event_tx, history: ChatComposerHistory::new(), - quit_shortcut_expires_at: None, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - esc_backtrack_hint: false, - use_shift_enter_hint, - pending_pastes: Vec::new(), + footer: FooterState { + quit_shortcut_expires_at: None, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + esc_backtrack_hint: false, + use_shift_enter_hint, + mode: FooterMode::ComposerEmpty, + hint_override: None, + plan_mode_nudge_visible: false, + flash: None, + context_window_percent: None, + context_window_used_tokens: None, + collaboration_mode_indicator: None, + goal_status_indicator: None, + ide_context_active: false, + status_line_value: None, + status_line_hyperlink_url: None, + status_line_enabled: false, + side_conversation_context_label: None, + active_agent_label: None, + external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), + show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + insert_newline_key: footer_insert_newline_key( + &default_keymap.editor.insert_newline, + use_shift_enter_hint, + ), + queue_key: Some(key_hint::plain(KeyCode::Tab)), + toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), + history_search_key: primary_binding( + &default_keymap.composer.history_search_previous, + ), + reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort), + reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort), + }, has_focus: has_input_focus, frame_requester: None, attachments: AttachmentState::default(), placeholder_text, is_task_running: false, - input_enabled: true, - input_disabled_placeholder: None, - paste_burst: PasteBurst::default(), - disable_paste_burst: false, - footer_mode: FooterMode::ComposerEmpty, - footer_hint_override: None, - plan_mode_nudge_visible: false, pending_slash_command_history: None, - footer_flash: None, - context_window_percent: None, #[cfg(not(target_os = "linux"))] next_element_id: 0, - context_window_used_tokens: None, skills: None, plugins: None, connectors_snapshot: None, - mention_bindings: HashMap::new(), - recent_submission_mention_bindings: Vec::new(), collaboration_modes_enabled: false, config, - collaboration_mode_indicator: None, - goal_status_indicator: None, - ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, service_tier_commands_enabled: false, @@ -573,11 +543,6 @@ impl ChatComposer { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, side_conversation_active: false, - status_line_value: None, - status_line_hyperlink_url: None, - status_line_enabled: false, - side_conversation_context_label: None, - active_agent_label: None, history_search: None, submit_keys: vec![key_hint::plain(KeyCode::Enter)], queue_keys: vec![key_hint::plain(KeyCode::Tab)], @@ -589,23 +554,6 @@ impl ChatComposer { history_search_next_keys: default_keymap.composer.history_search_next.clone(), editor_keymap: default_editor_keymap, vim_normal_keymap: default_vim_normal_keymap, - footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), - footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), - footer_insert_newline_key: footer_insert_newline_key( - &default_keymap.editor.insert_newline, - use_shift_enter_hint, - ), - footer_queue_key: Some(key_hint::plain(KeyCode::Tab)), - footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), - footer_history_search_key: primary_binding( - &default_keymap.composer.history_search_previous, - ), - footer_reasoning_down_key: primary_binding( - &default_keymap.chat.decrease_reasoning_effort, - ), - footer_reasoning_up_key: primary_binding( - &default_keymap.chat.increase_reasoning_effort, - ), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -659,7 +607,7 @@ impl ChatComposer { let elements = self.current_mention_elements(); let mut ordered = Vec::new(); for (id, mention) in elements { - if let Some(binding) = self.mention_bindings.remove(&id) + if let Some(binding) = self.draft.mention_bindings.remove(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -668,7 +616,7 @@ impl ChatComposer { }); } } - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); ordered } @@ -707,31 +655,33 @@ impl ChatComposer { self.history_search_next_keys = keymap.composer.history_search_next.clone(); self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); - self.textarea.set_keymap_bindings(keymap); - self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); - self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); - self.footer_insert_newline_key = - footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint); - self.footer_queue_key = primary_binding(&keymap.composer.queue); - self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); - self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous); - self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); - self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); + self.draft.textarea.set_keymap_bindings(keymap); + self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor); + self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript); + self.footer.insert_newline_key = footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ); + self.footer.queue_key = primary_binding(&keymap.composer.queue); + self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); + self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous); + self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); + self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); } pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, ) { - self.collaboration_mode_indicator = indicator; + self.footer.collaboration_mode_indicator = indicator; } pub fn set_goal_status_indicator(&mut self, indicator: Option) { - self.goal_status_indicator = indicator; + self.footer.goal_status_indicator = indicator; } pub fn set_ide_context_active(&mut self, active: bool) { - self.ide_context_active = active; + self.footer.ide_context_active = active; } pub fn set_personality_command_enabled(&mut self, enabled: bool) { @@ -842,7 +792,7 @@ impl ChatComposer { area: Rect, textarea_right_reserve: u16, ) -> Option<(u16, u16)> { - if !self.input_enabled || self.attachments.selected_remote_image_index.is_some() { + if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { return None; } @@ -852,12 +802,14 @@ impl ChatComposer { let [_, _, textarea_rect, _] = self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(textarea_rect, state) } /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { - self.textarea.is_empty() && !self.is_bash_mode && self.attachments.is_empty() + self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() } /// Record local persistent-history metadata so the composer can navigate @@ -924,17 +876,17 @@ impl ChatComposer { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); - self.textarea.insert_element(&placeholder); - self.pending_pastes.push((placeholder, pasted)); + self.draft.textarea.insert_element(&placeholder); + self.draft.pending_pastes.push((placeholder, pasted)); } else if char_count > 1 && self.image_paste_enabled() && self.handle_paste_image_path(pasted.clone()) { - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } else { self.insert_str(&pasted); } - self.paste_burst.clear_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); self.sync_popups(); true } @@ -981,13 +933,13 @@ impl ChatComposer { /// without emitting any buffered text, which can leave a non-empty buffer unable to flush /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.disable_paste_burst; - self.disable_paste_burst = disabled; + let was_disabled = self.draft.disable_paste_burst; + self.draft.disable_paste_burst = disabled; if disabled && !was_disabled { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.paste_burst.clear_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); } } @@ -997,7 +949,7 @@ impl ChatComposer { /// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of /// remote images). Cursor is placed at the end after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); let (text, _) = self.imported_text_for_textarea(text, Vec::new()); // Count placeholder occurrences in the new text. @@ -1030,7 +982,7 @@ impl ChatComposer { self.attachments.local_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text_clearing_elements(""); + self.draft.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attachments.local_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -1053,20 +1005,23 @@ impl ChatComposer { continue; } if pos > idx { - self.textarea.insert_str(&text[idx..pos]); + self.draft.textarea.insert_str(&text[idx..pos]); } - self.textarea.insert_element(ph); + self.draft.textarea.insert_element(ph); *count -= 1; idx = pos + ph.len(); } if idx < text.len() { - self.textarea.insert_str(&text[idx..]); + self.draft.textarea.insert_str(&text[idx..]); } // Keep local image placeholders normalized in attachment order after the // remote-image prefix. - self.attachments.relabel_local_images(&mut self.textarea); - self.textarea.set_cursor(self.textarea.text().len()); + self.attachments + .relabel_local_images(&mut self.draft.textarea); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } @@ -1077,9 +1032,9 @@ impl ChatComposer { /// commands, not as candidate literal paste text. It also resets transient /// footer mode so the visible hints match the new editing surface. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { - self.textarea.set_vim_enabled(enabled); - self.paste_burst.clear_after_explicit_paste(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.draft.textarea.set_vim_enabled(enabled); + self.draft.paste_burst.clear_after_explicit_paste(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } /// Toggle Vim editing and return the new enabled state. @@ -1088,7 +1043,7 @@ impl ChatComposer { /// keybinding; callers should use the returned value for status messages /// instead of rereading state after additional composer mutations. pub(crate) fn toggle_vim_enabled(&mut self) -> bool { - let enabled = !self.textarea.is_vim_enabled(); + let enabled = !self.draft.textarea.is_vim_enabled(); self.set_vim_enabled(enabled); enabled } @@ -1096,7 +1051,7 @@ impl ChatComposer { /// Return whether Vim editing is enabled for tests that assert mode transitions. #[cfg(test)] pub(crate) fn is_vim_enabled(&self) -> bool { - self.textarea.is_vim_enabled() + self.draft.textarea.is_vim_enabled() } /// Return whether Escape should be routed to the textarea before popups. @@ -1105,15 +1060,20 @@ impl ChatComposer { /// event layer asks this before running generic Escape behavior so the same /// key does not both leave insert mode and dismiss unrelated UI. pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { - self.textarea.should_handle_vim_insert_escape(key_event) + self.draft + .textarea + .should_handle_vim_insert_escape(key_event) } fn vim_mode_indicator_span(&self) -> Option> { - self.textarea.vim_mode_label().map(|label| match label { - "Normal" => "Vim: Normal".magenta(), - "Insert" => "Vim: Insert".green(), - _ => unreachable!(), - }) + self.draft + .textarea + .vim_mode_label() + .map(|label| match label { + "Normal" => "Vim: Normal".magenta(), + "Insert" => "Vim: Insert".green(), + _ => unreachable!(), + }) } fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { @@ -1122,9 +1082,9 @@ impl ChatComposer { spans.push(vim_mode); } if let Some(indicators) = status_line_right_indicator_line( - self.collaboration_mode_indicator, - self.goal_status_indicator.as_ref(), - self.ide_context_active, + self.footer.collaboration_mode_indicator, + self.footer.goal_status_indicator.as_ref(), + self.footer.ide_context_active, show_cycle_hint, ) { if !spans.is_empty() { @@ -1140,8 +1100,10 @@ impl ChatComposer { } fn right_footer_line_with_context(&self) -> Line<'static> { - let mut line = - context_window_line(self.context_window_percent, self.context_window_used_tokens); + let mut line = context_window_line( + self.footer.context_window_percent, + self.footer.context_window_used_tokens, + ); if let Some(vim_mode) = self.vim_mode_indicator_span() { line.spans.push(" | ".dim()); line.spans.push(vim_mode); @@ -1151,27 +1113,30 @@ impl ChatComposer { pub(crate) fn current_text_with_pending(&self) -> String { let text = self.current_text(); - if self.pending_pastes.is_empty() { + if self.draft.pending_pastes.is_empty() { return text; } - let (text, _) = - Self::expand_pending_pastes(&text, self.current_text_elements(), &self.pending_pastes); + let (text, _) = Self::expand_pending_pastes( + &text, + self.current_text_elements(), + &self.draft.pending_pastes, + ); text } /// Returns whether the composer currently accepts interactive draft edits. pub(crate) fn input_enabled(&self) -> bool { - self.input_enabled + self.draft.input_enabled } pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> { - self.pending_pastes.clone() + self.draft.pending_pastes.clone() } pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { let text = self.current_text(); - self.pending_pastes = pending_pastes + self.draft.pending_pastes = pending_pastes .into_iter() .filter(|(placeholder, _)| text.contains(placeholder)) .collect(); @@ -1180,7 +1145,7 @@ impl ChatComposer { /// Override the footer hint items displayed beneath the composer. Passing /// `None` restores the default shortcut footer. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { - self.footer_hint_override = items; + self.footer.hint_override = items; } /// Updates whether the Plan-mode nudge replaces the ambient footer row. @@ -1188,21 +1153,21 @@ impl ChatComposer { /// Returns `true` only when the rendered footer can change so callers can avoid scheduling /// redundant redraws while reevaluating nudge policy on routine composer updates. pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) -> bool { - if self.plan_mode_nudge_visible == visible { + if self.footer.plan_mode_nudge_visible == visible { return false; } - self.plan_mode_nudge_visible = visible; + self.footer.plan_mode_nudge_visible = visible; true } #[cfg(test)] pub(crate) fn plan_mode_nudge_visible(&self) -> bool { - self.plan_mode_nudge_visible + self.footer.plan_mode_nudge_visible } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { self.attachments - .set_remote_image_urls(urls, &mut self.textarea); + .set_remote_image_urls(urls, &mut self.draft.textarea); self.sync_popups(); } @@ -1211,23 +1176,16 @@ impl ChatComposer { } pub(crate) fn take_remote_image_urls(&mut self) -> Vec { - let urls = self.attachments.take_remote_image_urls(&mut self.textarea); + let urls = self + .attachments + .take_remote_image_urls(&mut self.draft.textarea); self.sync_popups(); urls } #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - let expires_at = Instant::now() - .checked_add(duration) - .unwrap_or_else(Instant::now); - self.footer_flash = Some(FooterFlash { line, expires_at }); - } - - pub(crate) fn footer_flash_visible(&self) -> bool { - self.footer_flash - .as_ref() - .is_some_and(|flash| Instant::now() < flash.expires_at) + self.footer.show_flash(line, duration); } /// Replace the entire composer content with `text` and reset cursor. @@ -1270,31 +1228,33 @@ impl ChatComposer { mention_bindings: Vec, ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; - self.pending_pastes.clear(); - self.mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + self.draft.pending_pastes.clear(); + self.draft.mention_bindings.clear(); let (text, text_elements) = self.imported_text_for_textarea(text, text_elements); - self.textarea.set_text_with_elements(&text, &text_elements); + self.draft + .textarea + .set_text_with_elements(&text, &text_elements); self.attachments - .reset_local_images(local_image_paths, &mut self.textarea); + .reset_local_images(local_image_paths, &mut self.draft.textarea); self.bind_mentions_from_snapshot(mention_bindings); - self.textarea.set_cursor(/*pos*/ 0); + self.draft.textarea.set_cursor(/*pos*/ 0); self.sync_popups(); } fn current_cursor(&self) -> usize { - self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 } + self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } fn history_navigation_cursor(&self) -> usize { - if self.is_bash_mode && self.textarea.cursor() == 0 { + if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 - } else if self.textarea.is_vim_normal_mode() - && !self.textarea.text().is_empty() - && self.textarea.cursor() == self.textarea.vim_normal_end_cursor() + } else if self.draft.textarea.is_vim_normal_mode() + && !self.draft.textarea.text().is_empty() + && self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor() { self.current_text().len() } else { @@ -1303,18 +1263,20 @@ impl ChatComposer { } fn set_current_cursor(&mut self, cursor: usize) { - let visible_cursor = if self.is_bash_mode { + let visible_cursor = if self.draft.is_bash_mode { cursor.saturating_sub(1) } else { cursor }; - self.textarea - .set_cursor(visible_cursor.min(self.textarea.text().len())); + self.draft + .textarea + .set_cursor(visible_cursor.min(self.draft.textarea.text().len())); } fn current_text_elements(&self) -> Vec { - let shift = if self.is_bash_mode { 1 } else { 0 }; - self.textarea + let shift = if self.draft.is_bash_mode { 1 } else { 0 }; + self.draft + .textarea .text_elements() .into_iter() .filter_map(|element| Self::shift_text_element(element, shift)) @@ -1338,7 +1300,7 @@ impl ChatComposer { local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), cursor: self.current_cursor(), } } @@ -1372,17 +1334,19 @@ impl ChatComposer { /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { - self.textarea.set_cursor(self.textarea.text().len()); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } fn move_cursor_to_history_entry_end(&mut self) { - let cursor = if self.textarea.is_vim_normal_mode() { - self.textarea.vim_normal_end_cursor() + let cursor = if self.draft.textarea.is_vim_normal_mode() { + self.draft.textarea.vim_normal_end_cursor() } else { - self.textarea.text().len() + self.draft.textarea.text().len() }; - self.textarea.set_cursor(cursor); + self.draft.textarea.set_cursor(cursor); self.sync_popups(); } @@ -1396,7 +1360,7 @@ impl ChatComposer { text_elements: Vec, ) -> (String, Vec) { if let Some(stripped) = text.strip_prefix('!') { - self.is_bash_mode = true; + self.draft.is_bash_mode = true; ( stripped.to_string(), text_elements @@ -1405,7 +1369,7 @@ impl ChatComposer { .collect(), ) } else { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; (text, text_elements) } } @@ -1417,7 +1381,7 @@ impl ChatComposer { let previous = self.current_text(); let text_elements = self.current_text_elements(); let local_image_paths = self.attachments.local_image_paths(); - let pending_pastes = std::mem::take(&mut self.pending_pastes); + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); let remote_image_urls = self.attachments.remote_image_urls(); let mention_bindings = self.snapshot_mention_bindings(); self.set_text_content(String::new(), Vec::new(), Vec::new()); @@ -1436,10 +1400,10 @@ impl ChatComposer { /// Get the current composer text. pub(crate) fn current_text(&self) -> String { - if self.is_bash_mode { - format!("!{}", self.textarea.text()) + if self.draft.is_bash_mode { + format!("!{}", self.draft.textarea.text()) } else { - self.textarea.text().to_string() + self.draft.textarea.text().to_string() } } @@ -1474,6 +1438,17 @@ impl ChatComposer { self.current_text_elements() } + pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { + ComposerDraftSnapshot { + text: self.current_text(), + text_elements: self.text_elements(), + local_images: self.local_images(), + remote_image_urls: self.remote_image_urls(), + mention_bindings: self.mention_bindings(), + pending_pastes: self.pending_pastes(), + } + } + #[cfg(test)] pub(crate) fn local_image_paths(&self) -> Vec { self.attachments.local_image_paths() @@ -1481,12 +1456,7 @@ impl ChatComposer { #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { - self.status_line_value.as_ref().map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + self.footer.status_line_text() } pub(crate) fn local_images(&self) -> Vec { @@ -1498,7 +1468,7 @@ impl ChatComposer { } pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec { - std::mem::take(&mut self.recent_submission_mention_bindings) + std::mem::take(&mut self.draft.recent_submission_mention_bindings) } /// Commit the staged slash-command draft to local Up-arrow recall. @@ -1513,7 +1483,8 @@ impl ChatComposer { /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { - self.attachments.attach_image(&mut self.textarea, path); + self.attachments + .attach_image(&mut self.draft.textarea, path); } #[cfg(test)] @@ -1545,7 +1516,7 @@ impl ChatComposer { /// This includes actively buffering, having a non-empty burst buffer, or holding the first /// ASCII char for flicker suppression. pub(crate) fn is_in_paste_burst(&self) -> bool { - self.paste_burst.is_active() + self.draft.paste_burst.is_active() } /// Returns a delay that reliably exceeds the paste-burst timing threshold. @@ -1561,7 +1532,7 @@ impl ChatComposer { let current_opt = if self.mentions_v2_enabled { self.current_mentions_v2_token() } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let Some(current_token) = current_opt else { return; @@ -1588,18 +1559,18 @@ impl ChatComposer { /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear /// even when the UI is otherwise idle. pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { - self.quit_shortcut_expires_at = Instant::now() + self.footer.quit_shortcut_expires_at = Instant::now() .checked_add(super::QUIT_SHORTCUT_TIMEOUT) .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = key; - self.footer_mode = FooterMode::QuitShortcutReminder; + self.footer.quit_shortcut_key = key; + self.footer.mode = FooterMode::QuitShortcutReminder; self.set_has_focus(has_focus); } /// Clear the "press again to quit" hint immediately. pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { - self.quit_shortcut_expires_at = None; - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.set_has_focus(has_focus); } @@ -1609,7 +1580,8 @@ impl ChatComposer { /// any additional user input, so the UI schedules a redraw when the hint /// expires. pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.quit_shortcut_expires_at + self.footer + .quit_shortcut_expires_at .is_some_and(|expires_at| Instant::now() < expires_at) } @@ -1618,7 +1590,7 @@ impl ChatComposer { let prefix = format!("{base} #"); let mut max_suffix = 0usize; - for (placeholder, _) in &self.pending_pastes { + for (placeholder, _) in &self.draft.pending_pastes { if placeholder == &base { max_suffix = max_suffix.max(1); continue; @@ -1638,14 +1610,14 @@ impl ChatComposer { } pub(crate) fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); + self.draft.textarea.insert_str(text); self.sync_bash_mode_from_text(); self.sync_popups(); } /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if !self.input_enabled { + if !self.draft.input_enabled { return (InputResult::None, false); } @@ -1685,13 +1657,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::Command(popup) = &mut self.popups.active else { unreachable!(); @@ -1733,7 +1705,7 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); @@ -1741,18 +1713,21 @@ impl ChatComposer { && cmd == SlashCommand::Skills { self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd)); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return (InputResult::Command(cmd), true); } let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } return (InputResult::None, true); } @@ -1769,19 +1744,22 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - self.is_bash_mode = false; + self.draft.is_bash_mode = false; } - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } } (InputResult::None, true) @@ -1793,8 +1771,8 @@ impl ChatComposer { } => { if let Some(sel) = popup.selected_item() { self.stage_selected_slash_command_history(&sel); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return ( match sel { CommandItem::Builtin(cmd) => InputResult::Command(cmd), @@ -1842,12 +1820,13 @@ impl ChatComposer { /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { - if self.disable_paste_burst { + if self.draft.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + self.draft.textarea.input(input); + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); return (InputResult::None, true); } @@ -1856,7 +1835,7 @@ impl ChatComposer { .. } = input { - if self.paste_burst.try_append_char_if_active(ch, now) { + if self.draft.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } // Non-ASCII input often comes from IMEs and can arrive in quick bursts. @@ -1864,32 +1843,35 @@ impl ChatComposer { // still want to detect paste-like bursts. Before applying any non-ASCII input, flush // any existing burst buffer (including a pending first char from the ASCII path) so // we don't carry that transient state forward. - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { + if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) { match decision { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { // For non-ASCII we inserted prior chars immediately, so if this turns out // to be paste-like we need to retroactively grab & remove the already- // inserted prefix from the textarea before buffering the burst. - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -1899,13 +1881,14 @@ impl ChatComposer { } } } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.textarea.input(input); + self.draft.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); (InputResult::None, true) } @@ -1916,13 +1899,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::File(popup) = &mut self.popups.active else { unreachable!(); @@ -1956,7 +1939,7 @@ impl ChatComposer { code: KeyCode::Esc, .. } => { // Hide popup without modifying text, remember token to avoid immediate reopen. - if let Some(tok) = Self::current_at_token(&self.textarea) { + if let Some(tok) = Self::current_at_token(&self.draft.textarea) { self.popups.dismissed_file_token = Some(tok); } self.popups.active = ActivePopup::None; @@ -1990,8 +1973,8 @@ impl ChatComposer { tracing::debug!("selected image dimensions={}x{}", width, height); // Remove the current @token (mirror logic from insert_selected_path without inserting text) // using the flat text and byte-offset cursor API. - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2010,12 +1993,12 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); // Add a trailing space to keep typing fluid. - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2038,7 +2021,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::Skill(popup) = &mut self.popups.active else { unreachable!(); @@ -2114,7 +2097,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -2219,8 +2202,8 @@ impl ChatComposer { match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; @@ -2237,10 +2220,10 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2511,14 +2494,14 @@ impl ChatComposer { if !self.mentions_v2_enabled { return None; } - Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true) } fn current_mention_token(&self) -> Option { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token(&self.textarea, '$', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -2527,8 +2510,8 @@ impl ChatComposer { /// where the cursor is within the token and regardless of how many /// `@tokens` exist in the line. fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -2561,15 +2544,16 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. - self.textarea + self.draft + .textarea .replace_range(start_idx..end_idx, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2589,14 +2573,14 @@ impl ChatComposer { let end_idx = safe_cursor + end_rel_idx; // Remove the active token and insert the selected mention as an atomic element. - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); - let id = self.textarea.insert_element(insert_text); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); + let id = self.draft.textarea.insert_element(insert_text); if let (Some(path), Some(mention)) = (path, Self::mention_name_from_insert_text(insert_text)) { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention, @@ -2605,11 +2589,11 @@ impl ChatComposer { ); } - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); let new_cursor = start_idx .saturating_add(insert_text.len()) .saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn mention_name_from_insert_text(insert_text: &str) -> Option { @@ -2629,7 +2613,8 @@ impl ChatComposer { } fn current_mention_elements(&self) -> Vec<(u64, String)> { - self.textarea + self.draft + .textarea .text_element_snapshots() .into_iter() .filter_map(|snapshot| { @@ -2642,7 +2627,7 @@ impl ChatComposer { fn snapshot_mention_bindings(&self) -> Vec { let mut ordered = Vec::new(); for (id, mention) in self.current_mention_elements() { - if let Some(binding) = self.mention_bindings.get(&id) + if let Some(binding) = self.draft.mention_bindings.get(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -2655,12 +2640,12 @@ impl ChatComposer { } fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec) { - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); if mention_bindings.is_empty() { return; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); let mut scan_from = 0usize; for binding in mention_bindings { let token = format!("${}", binding.mention); @@ -2670,14 +2655,16 @@ impl ChatComposer { continue; }; - let id = if let Some(id) = self.textarea.add_element_range(range.clone()) { + let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) { Some(id) } else { - self.textarea.element_id_for_exact_range(range.clone()) + self.draft + .textarea + .element_id_for_exact_range(range.clone()) }; if let Some(id) = id { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention: binding.mention, @@ -2710,17 +2697,17 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.recent_submission_mention_bindings.clear(); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.recent_submission_mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; - if !self.pending_pastes.is_empty() { + if !self.draft.pending_pastes.is_empty() { // Expand placeholders so element byte ranges stay aligned. let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes); text = expanded; text_elements = expanded_elements; } @@ -2756,8 +2743,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } } @@ -2775,8 +2764,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } self.attachments @@ -2784,7 +2775,7 @@ impl ChatComposer { if text.is_empty() && self.attachments.is_empty() { return None; } - self.recent_submission_mention_bindings = original_mention_bindings.clone(); + self.draft.recent_submission_mention_bindings = original_mention_bindings.clone(); if record_history && (!text.is_empty() || !self.attachments.is_empty()) { self.history.record_local_submission(HistoryEntry { text: text.clone(), @@ -2795,7 +2786,7 @@ impl ChatComposer { pending_pastes: Vec::new(), }); } - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); Some((text, text_elements)) } @@ -2816,7 +2807,7 @@ impl ChatComposer { | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { - self.textarea.enter_vim_normal_mode(); + self.draft.textarea.enter_vim_normal_mode(); } } @@ -2826,7 +2817,7 @@ impl ChatComposer { now: Instant, ) -> (InputResult, bool) { if should_queue { - let raw_text = self.textarea.text(); + let raw_text = self.draft.textarea.text(); let defer_slash_validation = self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); if let Some((text, text_elements)) = self.prepare_submission_text_with_options( @@ -2865,32 +2856,34 @@ impl ChatComposer { // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. let in_slash_context = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && (matches!(self.popups.active, ActivePopup::Command(_)) || self + .draft .textarea .text() .lines() .next() .unwrap_or("") .starts_with('/')); - if !self.disable_paste_burst - && self.paste_burst.is_active() + if !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() && !in_slash_context - && self.paste_burst.append_newline_if_active(now) + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. if !in_slash_context - && !self.disable_paste_burst + && !self.draft.disable_paste_burst && self + .draft .paste_burst .newline_should_insert_instead_of_submit(now) { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); return (InputResult::None, true); } @@ -2898,7 +2891,7 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } @@ -2934,7 +2927,7 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes = original_pending_pastes; + self.draft.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -2942,10 +2935,10 @@ impl ChatComposer { /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line = text.lines().next().unwrap_or(""); let (name, rest, _rest_offset) = parse_slash_name(first_line)?; if !rest.is_empty() { @@ -2967,8 +2960,8 @@ impl ChatComposer { return Some(InputResult::None); } self.stage_slash_command_history(&command); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; Some(match command { SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command), @@ -2978,10 +2971,10 @@ impl ChatComposer { /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); if text.starts_with(' ') { return None; } @@ -3008,8 +3001,11 @@ impl ChatComposer { self.stage_slash_command_history(&command); - let mut args_elements = - Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); + let mut args_elements = Self::slash_command_args_elements( + rest, + rest_offset, + &self.draft.textarea.text_elements(), + ); let trimmed_rest = rest.trim(); args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); let SlashCommandItem::Builtin(cmd) = command else { @@ -3089,7 +3085,7 @@ impl ChatComposer { if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { return; } - self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); + self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string()); } /// Stage a popup-selected command using its canonical command text. @@ -3111,11 +3107,11 @@ impl ChatComposer { fn stage_slash_command_history_text(&mut self, text: String) { self.pending_slash_command_history = Some(HistoryEntry { text, - text_elements: self.textarea.text_elements(), + text_elements: self.draft.textarea.text_elements(), local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), }); } @@ -3152,7 +3148,7 @@ impl ChatComposer { key_event: &KeyEvent, ) -> Option<(InputResult, bool)> { self.attachments - .handle_remote_image_selection_key(key_event, &mut self.textarea) + .handle_remote_image_selection_key(key_event, &mut self.draft.textarea) } /// Handle key event when no popup is visible. @@ -3166,22 +3162,23 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - if self.is_bash_mode && key_event.code == KeyCode::Esc { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - if self.textarea.is_empty() { - self.is_bash_mode = false; + if self.draft.textarea.is_empty() { + self.draft.is_bash_mode = false; return (InputResult::None, true); } } if self.should_handle_vim_insert_escape(key_event) { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() && self.textarea.is_vim_operator_pending() { + if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending() + { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3193,13 +3190,15 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.textarea.set_text_clearing_elements("/"); - self.textarea.set_cursor(self.textarea.text().len()); - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.textarea.set_text_clearing_elements("/"); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3211,21 +3210,21 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.is_bash_mode = true; - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.is_bash_mode = true; + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } if key_event.code == KeyCode::Esc { if self.is_empty() { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } if self.queue_keys.is_pressed(key_event) && (self.is_task_running || !self.is_bang_shell_command()) @@ -3248,8 +3247,9 @@ impl ChatComposer { return (InputResult::None, false); } - let (history_up_pressed, history_down_pressed) = if self.textarea.is_vim_normal_mode() { - if self.textarea.is_vim_operator_pending() { + let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode() + { + if self.draft.textarea.is_vim_operator_pending() { (false, false) } else { ( @@ -3303,7 +3303,7 @@ impl ChatComposer { /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { - match self.paste_burst.flush_if_due(now) { + match self.draft.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { self.handle_paste(pasted); true @@ -3350,14 +3350,14 @@ impl ChatComposer { self.handle_paste_burst_flush(now); if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) - && !self.disable_paste_burst - && self.paste_burst.is_active() - && self.paste_burst.append_newline_if_active(now) + && !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } @@ -3374,31 +3374,37 @@ impl ChatComposer { } = input { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() { + if !has_ctrl_or_alt + && !self.draft.disable_paste_burst + && self.draft.textarea.allows_paste_burst() + { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { return self.handle_non_ascii_char(input, now); } - match self.paste_burst.on_plain_char(ch, now) { + match self.draft.paste_burst.on_plain_char(ch, now) { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -3406,7 +3412,7 @@ impl ChatComposer { } CharDecision::BeginBufferFromPending => { // First char was held; now append the current one. - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::RetainFirstChar => { @@ -3415,7 +3421,7 @@ impl ChatComposer { } } } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } } @@ -3427,28 +3433,29 @@ impl ChatComposer { // time out against, and the buffered paste could remain stuck until another plain char // arrives. if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) - && let Some(pasted) = self.paste_burst.flush_before_modified_input() + && let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) - let elements_before = if self.pending_pastes.is_empty() && self.attachments.is_empty() { + let elements_before = if self.draft.pending_pastes.is_empty() && self.attachments.is_empty() + { None } else { - Some(self.textarea.element_payloads()) + Some(self.draft.textarea.element_payloads()) }; - if self.is_bash_mode + if self.draft.is_bash_mode && matches!(input.code, KeyCode::Backspace) - && self.textarea.cursor() == 0 + && self.draft.textarea.cursor() == 0 { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; return (InputResult::None, true); } - self.textarea.input(input); + self.draft.textarea.input(input); self.sync_bash_mode_from_text(); if let Some(elements_before) = elements_before { @@ -3463,7 +3470,7 @@ impl ChatComposer { KeyCode::Char(_) => { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); if has_ctrl_or_alt { - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } KeyCode::Enter => { @@ -3471,7 +3478,7 @@ impl ChatComposer { } _ => { // Other keys: clear burst window (buffer should have been flushed above if needed). - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } @@ -3479,25 +3486,25 @@ impl ChatComposer { } fn sync_bash_mode_from_text(&mut self) { - if !self.is_bash_mode && self.textarea.text().starts_with('!') { - self.textarea.replace_range(0..1, ""); - self.is_bash_mode = true; + if !self.draft.is_bash_mode && self.draft.textarea.text().starts_with('!') { + self.draft.textarea.replace_range(0..1, ""); + self.draft.is_bash_mode = true; } } fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = - self.textarea.element_payloads().into_iter().collect(); + self.draft.textarea.element_payloads().into_iter().collect(); let removed_payloads = elements_before .into_iter() .filter(|payload| !elements_after.contains(payload)) .collect::>(); for removed in &removed_payloads { - self.pending_pastes.retain(|(ph, _)| ph != removed); + self.draft.pending_pastes.retain(|(ph, _)| ph != removed); } self.attachments - .remove_deleted_local_placeholders(&removed_payloads, &mut self.textarea); + .remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea); } /// Handle the dedicated shortcut-overlay toggle key(s). @@ -3520,12 +3527,12 @@ impl ChatComposer { } let next = toggle_shortcut_mode( - self.footer_mode, + self.footer.mode, self.quit_shortcut_hint_visible(), self.is_empty(), ); - let changed = next != self.footer_mode; - self.footer_mode = next; + let changed = next != self.footer.mode; + self.footer.mode = next; changed } @@ -3544,26 +3551,26 @@ impl ChatComposer { FooterProps { mode, - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, + esc_backtrack_hint: self.footer.esc_backtrack_hint, + use_shift_enter_hint: self.footer.use_shift_enter_hint, is_task_running: self.is_task_running, - quit_shortcut_key: self.quit_shortcut_key, + quit_shortcut_key: self.footer.quit_shortcut_key, collaboration_modes_enabled: self.collaboration_modes_enabled, is_wsl, - status_line_value: self.status_line_value.clone(), - status_line_enabled: self.status_line_enabled, + status_line_value: self.footer.status_line_value.clone(), + status_line_enabled: self.footer.status_line_enabled, key_hints: FooterKeyHints { - toggle_shortcuts: self.footer_toggle_shortcuts_key, - queue: self.footer_queue_key, - insert_newline: self.footer_insert_newline_key, - external_editor: self.footer_external_editor_key, + toggle_shortcuts: self.footer.toggle_shortcuts_key, + queue: self.footer.queue_key, + insert_newline: self.footer.insert_newline_key, + external_editor: self.footer.external_editor_key, edit_previous: Some(key_hint::plain(KeyCode::Esc)), - show_transcript: self.footer_show_transcript_key, - history_search: self.footer_history_search_key, - reasoning_down: self.footer_reasoning_down_key, - reasoning_up: self.footer_reasoning_up_key, + show_transcript: self.footer.show_transcript_key, + history_search: self.footer.history_search_key, + reasoning_down: self.footer.reasoning_down_key, + reasoning_up: self.footer.reasoning_up_key, }, - active_agent_label: self.active_agent_label.clone(), + active_agent_label: self.footer.active_agent_label.clone(), } } @@ -3584,7 +3591,7 @@ impl ChatComposer { FooterMode::ComposerHasDraft }; - match self.footer_mode { + match self.footer.mode { FooterMode::HistorySearch => FooterMode::HistorySearch, FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, @@ -3602,10 +3609,11 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { - if self.footer_flash_visible() { + if self.footer.flash_visible() { return Some(1); } - self.footer_hint_override + self.footer + .hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) } @@ -3631,7 +3639,7 @@ impl ChatComposer { let file_token = if self.mentions_v2_enabled { None } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let browsing_history = self .history @@ -3650,7 +3658,7 @@ impl ChatComposer { let mention_token = self.current_mention_token(); let allow_command_popup = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && file_token.is_none() && mentions_v2_token.is_none() && mention_token.is_none(); @@ -3707,7 +3715,7 @@ impl ChatComposer { if !self.slash_commands_enabled() { return; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; let desired_range = self.slash_command_element_range(first_line); @@ -3715,7 +3723,7 @@ impl ChatComposer { // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; let mut stale_ranges = Vec::new(); - for elem in self.textarea.text_elements() { + for elem in self.draft.textarea.text_elements() { let Some(payload) = elem.placeholder(text) else { continue; }; @@ -3731,18 +3739,18 @@ impl ChatComposer { } for range in stale_ranges { - self.textarea.remove_element_range(range); + self.draft.textarea.remove_element_range(range); } if let Some(range) = desired_range && !has_desired { - self.textarea.add_element_range(range); + self.draft.textarea.add_element_range(range); } } fn slash_command_element_range(&self, first_line: &str) -> Option> { - if self.is_bash_mode { + if self.draft.is_bash_mode { return None; } let (name, _rest, _rest_offset) = parse_slash_name(first_line)?; @@ -3830,10 +3838,10 @@ impl ChatComposer { return; } // Determine whether the caret is inside the initial '/name' token on the first line. - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let cursor = self.textarea.cursor(); + let cursor = self.draft.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; let is_editing_slash_command_name = caret_on_first_line @@ -3843,7 +3851,7 @@ impl ChatComposer { // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path // as an argument to the command (e.g., "/review @docs/..."). - if Self::current_at_token(&self.textarea).is_some() { + if Self::current_at_token(&self.draft.textarea).is_some() { if matches!(self.popups.active, ActivePopup::Command(_)) { self.popups.active = ActivePopup::None; } @@ -4113,8 +4121,8 @@ impl ChatComposer { #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { - self.input_enabled = enabled; - self.input_disabled_placeholder = if enabled { None } else { placeholder }; + self.draft.input_enabled = enabled; + self.draft.input_disabled_placeholder = if enabled { None } else { placeholder }; // Avoid leaving interactive popups open while input is blocked. if !enabled && self.popups.active() { @@ -4127,52 +4135,53 @@ impl ChatComposer { } pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + if self.footer.context_window_percent == percent + && self.footer.context_window_used_tokens == used_tokens { return; } - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; + self.footer.context_window_percent = percent; + self.footer.context_window_used_tokens = used_tokens; } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { - self.esc_backtrack_hint = show; + self.footer.esc_backtrack_hint = show; if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + self.footer.mode = esc_hint_mode(self.footer.mode, self.is_task_running); } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } } pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { - if self.status_line_value == status_line { + if self.footer.status_line_value == status_line { return false; } - self.status_line_value = status_line; + self.footer.status_line_value = status_line; true } pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { - if self.status_line_hyperlink_url == url { + if self.footer.status_line_hyperlink_url == url { return false; } - self.status_line_hyperlink_url = url; + self.footer.status_line_hyperlink_url = url; true } pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { - if self.status_line_enabled == enabled { + if self.footer.status_line_enabled == enabled { return false; } - self.status_line_enabled = enabled; + self.footer.status_line_enabled = enabled; true } pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { - if self.side_conversation_context_label == label { + if self.footer.side_conversation_context_label == label { return false; } - self.side_conversation_context_label = label; + self.footer.side_conversation_context_label = label; true } @@ -4182,10 +4191,10 @@ impl ChatComposer { /// field is intentionally just cached presentation state; `ChatComposer` does not infer which /// thread is active on its own. pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) -> bool { - if self.active_agent_label == active_agent_label { + if self.footer.active_agent_label == active_agent_label { return false; } - self.active_agent_label = active_agent_label; + self.footer.active_agent_label = active_agent_label; true } } @@ -4210,17 +4219,17 @@ fn footer_insert_newline_key( #[cfg(not(target_os = "linux"))] impl ChatComposer { pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { - self.textarea.update_named_element_by_id(id, text) + self.draft.textarea.update_named_element_by_id(id, text) } pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { let id = self.next_id(); - self.textarea.insert_named_element(text, id.clone()); + self.draft.textarea.insert_named_element(text, id.clone()); id } pub fn remove_recording_meter_placeholder(&mut self, id: &str) { - let _ = self.textarea.replace_element_by_id(id, ""); + let _ = self.draft.textarea.replace_element_by_id(id, ""); } } @@ -4281,7 +4290,7 @@ impl Renderable for ChatComposer { } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { - if self.textarea.uses_vim_insert_cursor() { + if self.draft.textarea.uses_vim_insert_cursor() { crossterm::cursor::SetCursorStyle::SteadyBar } else { crossterm::cursor::SetCursorStyle::DefaultUserShape @@ -4319,7 +4328,7 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.textarea.desired_height(inner_width) + self.draft.textarea.desired_height(inner_width) + remote_images_height + remote_images_separator + 2 @@ -4364,8 +4373,8 @@ impl ChatComposer { } ActivePopup::None => { let footer_props = self.footer_props(); - let show_cycle_hint = - !footer_props.is_task_running && self.collaboration_mode_indicator.is_some(); + let show_cycle_hint = !footer_props.is_task_running + && self.footer.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), FooterMode::ComposerHasDraft => false, @@ -4398,7 +4407,7 @@ impl ChatComposer { }; if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); - } else if self.plan_mode_nudge_visible { + } else if self.footer.plan_mode_nudge_visible { let available_width = hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; render_footer_line( @@ -4428,11 +4437,12 @@ impl ChatComposer { let left_mode_indicator = if status_line_active { None } else { - self.collaboration_mode_indicator + self.footer.collaboration_mode_indicator }; - let active_footer_hint_override = self.footer_hint_override.as_ref(); - let mut left_width = if self.footer_flash_visible() { - self.footer_flash + let active_footer_hint_override = self.footer.hint_override.as_ref(); + let mut left_width = if self.footer.flash_visible() { + self.footer + .flash .as_ref() .map(|flash| flash.line.width() as u16) .unwrap_or(0) @@ -4453,7 +4463,7 @@ impl ChatComposer { ) }; let right_line = - if let Some(label) = self.side_conversation_context_label.as_ref() { + if let Some(label) = self.footer.side_conversation_context_label.as_ref() { Some(side_conversation_context_line(label)) } else if let Some(line) = self.shell_mode_footer_line() { Some(line) @@ -4483,7 +4493,7 @@ impl ChatComposer { let can_show_left_and_context = can_show_left_with_context(hint_rect, left_width, right_width); let has_override = - self.footer_flash_visible() || active_footer_hint_override.is_some(); + self.footer.flash_visible() || active_footer_hint_override.is_some(); let single_line_layout = if has_override || status_line_active { None } else { @@ -4558,8 +4568,8 @@ impl ChatComposer { } SummaryLeft::None => {} } - } else if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { + } else if self.footer.flash_visible() { + if let Some(flash) = self.footer.flash.as_ref() { flash.line.render(inset_footer_hint_area(hint_rect), buf); } } else if let Some(items) = active_footer_hint_override { @@ -4573,7 +4583,7 @@ impl ChatComposer { hint_rect, buf, &footer_props, - self.collaboration_mode_indicator, + self.footer.collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, @@ -4583,7 +4593,7 @@ impl ChatComposer { render_context_right(hint_rect, buf, line); } if status_line_active - && let Some(url) = self.status_line_hyperlink_url.as_deref() + && let Some(url) = self.footer.status_line_hyperlink_url.as_deref() { mark_underlined_hyperlink(buf, hint_rect, url); } @@ -4598,8 +4608,8 @@ impl ChatComposer { .render_ref(remote_images_rect, buf); } if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - if self.is_bash_mode { + let prompt = if self.draft.input_enabled { + if self.draft.is_bash_mode { Span::from("!").light_red().bold() } else { "›".bold() @@ -4615,15 +4625,21 @@ impl ChatComposer { ); } - let mut state = self.textarea_state.borrow_mut(); - let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode; + let mut state = self.draft.textarea_state.borrow_mut(); + let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; if let Some(mask_char) = mask_char { - self.textarea + self.draft + .textarea .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { let highlight_ranges = self.history_search_highlight_ranges(); if highlight_ranges.is_empty() { - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); } else { let highlight_style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); @@ -4631,7 +4647,7 @@ impl ChatComposer { .into_iter() .map(|range| (range, highlight_style)) .collect::>(); - self.textarea.render_ref_styled_with_highlights( + self.draft.textarea.render_ref_styled_with_highlights( textarea_rect, buf, &mut state, @@ -4641,10 +4657,11 @@ impl ChatComposer { } } if textarea_is_empty { - let text = if self.input_enabled { + let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { - self.input_disabled_placeholder + self.draft + .input_disabled_placeholder .as_deref() .unwrap_or("Input disabled.") .to_string() @@ -4788,8 +4805,8 @@ mod tests { let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤"); composer.remove_recording_meter_placeholder(&id); - assert_eq!(composer.textarea.text(), ""); - assert!(composer.textarea.named_element_range(&id).is_none()); + assert_eq!(composer.draft.textarea.text(), ""); + assert!(composer.draft.textarea.named_element_range(&id).is_none()); } #[test] @@ -4805,7 +4822,7 @@ mod tests { ); composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - composer.footer_flash.as_mut().unwrap().expires_at = + composer.footer.flash.as_mut().unwrap().expires_at = Instant::now() - Duration::from_secs(1); let area = Rect::new(0, 0, 60, 6); @@ -5114,7 +5131,7 @@ mod tests { ); type_chars_humanlike(&mut composer, &['!']); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); let (result, needs_redraw) = @@ -5122,7 +5139,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(!composer.is_bash_mode); + assert!(!composer.draft.is_bash_mode); assert_eq!(composer.current_text(), ""); } @@ -5152,7 +5169,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!g"); } @@ -5415,13 +5432,13 @@ mod tests { assert!(!composer.is_empty()); assert_eq!(composer.current_text(), "d"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert!(matches!(composer.popups.active, ActivePopup::None)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5458,8 +5475,8 @@ mod tests { composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5484,8 +5501,8 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "/"); - assert_eq!(composer.textarea.cursor(), "/".len()); + assert_eq!(composer.draft.textarea.text(), "/"); + assert_eq!(composer.draft.textarea.cursor(), "/".len()); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); assert_eq!( composer.vim_mode_indicator_span(), @@ -5513,7 +5530,7 @@ mod tests { for ch in ['/', 'd', 'i', 'f', 'f'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "/diff"); + assert_eq!(composer.draft.textarea.text(), "/diff"); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); let (result, needs_redraw) = @@ -5589,9 +5606,9 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5619,9 +5636,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!echo"); - assert_eq!(composer.textarea.text(), "echo"); + assert_eq!(composer.draft.textarea.text(), "echo"); assert!(matches!(composer.popups.active, ActivePopup::None)); } @@ -5642,7 +5659,7 @@ mod tests { type_chars_humanlike(&mut composer, &['d']); composer .show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), /*has_focus*/ true); - composer.quit_shortcut_expires_at = + composer.footer.quit_shortcut_expires_at = Some(Instant::now() - std::time::Duration::from_secs(1)); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); @@ -5693,9 +5710,9 @@ mod tests { composer.handle_paste(large.clone()); let char_count = large.chars().count(); let placeholder = format!("[Pasted Content {char_count} chars]"); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!( - composer.pending_pastes, + composer.draft.pending_pastes, vec![(placeholder.clone(), large.clone())] ); @@ -5721,9 +5738,15 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes, vec![(placeholder.clone(), large)]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!( + composer.draft.pending_pastes, + vec![(placeholder.clone(), large)] + ); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -5755,17 +5778,17 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone())); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -5786,7 +5809,7 @@ mod tests { composer.set_steer_enabled(/*enabled*/ true); composer.set_vim_enabled(/*enabled*/ true); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5797,7 +5820,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5867,7 +5890,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "/not-a-command"); + assert_eq!(composer.draft.textarea.text(), "/not-a-command"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5893,19 +5916,22 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) ); - assert_eq!(composer.textarea.cursor(), "hey".len()); + assert_eq!(composer.draft.textarea.cursor(), "hey".len()); composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.textarea.cursor(), "he".len()); + assert_eq!(composer.draft.textarea.cursor(), "he".len()); } #[test] @@ -5986,9 +6012,12 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!(composer.local_image_paths(), vec![path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); } #[test] @@ -6092,14 +6121,14 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); // Toggle back to prompt mode so subsequent typing captures characters. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); type_chars_humanlike(&mut composer, &['h']); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); let (result, needs_redraw) = @@ -6107,8 +6136,8 @@ mod tests { assert_eq!(result, InputResult::None); assert!(needs_redraw, "typing should still mark the view dirty"); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.draft.textarea.text(), "h?"); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); } @@ -6133,7 +6162,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); } /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut @@ -6156,6 +6185,7 @@ mod tests { // Force an active paste burst so this test doesn't depend on tight timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6163,12 +6193,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "hi?there"); - assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.draft.textarea.text(), "hi?there"); + assert_ne!(composer.footer.mode, FooterMode::ShortcutOverlay); } #[test] @@ -6528,11 +6558,11 @@ mod tests { ); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); composer.set_task_running(/*running*/ true); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); } @@ -6778,8 +6808,8 @@ mod tests { ); let input = "npx -y @kaeawc/auto-mobile@latest"; - composer.textarea.insert_str(input); - composer.textarea.set_cursor(input.len()); + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); composer.sync_popups(); assert!(matches!(composer.popups.active, ActivePopup::File(_))); @@ -6845,7 +6875,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "あ"); + assert_eq!(composer.draft.textarea.text(), "あ"); assert!(!composer.is_in_paste_burst()); } @@ -6868,6 +6898,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6877,9 +6908,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你好\nhi"); } /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should @@ -6901,6 +6932,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6912,9 +6944,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你 好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你 好\nhi"); } /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", @@ -6950,6 +6982,7 @@ mod tests { // Force an active burst so the test doesn't depend on timing heuristics. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6962,9 +6995,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD); + assert_eq!(composer.draft.textarea.text(), LARGE_MIXED_PAYLOAD); } /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a @@ -7013,11 +7046,11 @@ mod tests { ); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected paste burst to flush"); - assert_eq!(composer.textarea.text(), "hi\nthere"); + assert_eq!(composer.draft.textarea.text(), "hi\nthere"); } /// Behavior: even if Enter suppression would normally be active for a burst, Enter should @@ -7039,9 +7072,10 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/diff"); - composer.textarea.set_cursor("/diff".len()); + composer.draft.textarea.set_text_clearing_elements("/diff"); + composer.draft.textarea.set_cursor("/diff".len()); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -7071,17 +7105,18 @@ mod tests { // Force an active burst so we can deterministically buffer characters without relying on // timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); assert!(composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "hi"); - assert_eq!(composer.textarea.cursor(), 1); + assert_eq!(composer.draft.textarea.text(), "hi"); + assert_eq!(composer.draft.textarea.cursor(), 1); assert!(!composer.is_in_paste_burst()); } @@ -7107,14 +7142,14 @@ mod tests { // held char is not dropped. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); composer.set_disable_paste_burst(/*disabled*/ true); - assert_eq!(composer.textarea.text(), "a"); + assert_eq!(composer.draft.textarea.text(), "a"); assert!(!composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "ab"); + assert_eq!(composer.draft.textarea.text(), "ab"); assert!(!composer.is_in_paste_burst()); } @@ -7138,8 +7173,8 @@ mod tests { let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "hello"); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "hello"); + assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7166,7 +7201,7 @@ mod tests { ); // Ensure composer is empty and press Enter. - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7198,10 +7233,10 @@ mod tests { let needs_redraw = composer.handle_paste(large.clone()); assert!(needs_redraw); let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder); + assert_eq!(composer.draft.pending_pastes[0].1, large); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7209,7 +7244,7 @@ mod tests { InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7229,7 +7264,7 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7257,13 +7292,13 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7299,13 +7334,13 @@ mod tests { ); composer.set_steer_enabled(false); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7344,11 +7379,11 @@ mod tests { ); composer.handle_paste(large); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes.len(), 1); // Any edit that removes the placeholder should clear pending_paste composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7399,7 +7434,10 @@ mod tests { composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); // Move cursor to end and press backspace - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); } @@ -7458,7 +7496,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); }, ); @@ -7472,7 +7510,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = @@ -7750,7 +7788,10 @@ mod tests { } InputResult::None => panic!("expected Command result for '/init'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); } #[test] @@ -7769,22 +7810,22 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_steer_enabled(true); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("hello"); + composer.draft.textarea.insert_str("hello"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::Submitted { .. })); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7802,14 +7843,14 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("/diff"); + composer.draft.textarea.insert_str("/diff"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -7818,11 +7859,11 @@ mod tests { } _ => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7842,6 +7883,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements("/review these changes"); @@ -7849,7 +7891,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("/review these changes", composer.textarea.text()); + assert_eq!("/review these changes", composer.draft.textarea.text()); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7885,7 +7927,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -7902,7 +7944,7 @@ mod tests { } other => panic!("expected slash-led input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not report slash errors" @@ -7933,9 +7975,13 @@ mod tests { /*disable_paste_burst*/ false, ); composer + .draft .textarea .set_text_clearing_elements("explain the change"); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); let mut keymap = RuntimeKeymap::defaults(); keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))]; composer.set_keymap_bindings(&keymap); @@ -7944,7 +7990,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("explain the change\n", composer.textarea.text()); + assert_eq!("explain the change\n", composer.draft.textarea.text()); } #[test] @@ -7965,7 +8011,10 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements("queue me"); + composer + .draft + .textarea + .set_text_clearing_elements("queue me"); let mut keymap = RuntimeKeymap::defaults(); keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))]; composer.set_keymap_bindings(&keymap); @@ -7974,7 +8023,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("queue me", composer.textarea.text()); + assert_eq!("queue me", composer.draft.textarea.text()); } #[test] @@ -8022,6 +8071,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements(" /does-not-exist"); @@ -8054,7 +8104,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -8071,7 +8121,7 @@ mod tests { } other => panic!("expected bang shell input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not show shell help immediately" @@ -8104,8 +8154,11 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/compact "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/compact "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8131,8 +8184,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8157,8 +8213,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8178,7 +8237,7 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'd', 'i']); let (_res, _redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/diff "); + assert_eq!(composer.draft.textarea.text(), "/diff "); // Press Enter: should dispatch the command, not submit literal text. let (result, _needs_redraw) = @@ -8199,7 +8258,7 @@ mod tests { } InputResult::None => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8217,8 +8276,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/plan "); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some("/plan")); @@ -8239,8 +8298,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/Users "); assert!(elements.is_empty()); } @@ -8259,16 +8318,16 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/review "); assert_eq!(elements.len(), 1); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); type_chars_humanlike(&mut composer, &['x']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "x/review "); assert!(elements.is_empty()); } @@ -8298,7 +8357,7 @@ mod tests { result, InputResult::Submitted { ref text, .. } if text == "hi" )); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8396,9 +8455,12 @@ mod tests { } InputResult::None => panic!("expected Command result for '/mention'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); composer.insert_str("@"); - assert_eq!(composer.textarea.text(), "@"); + assert_eq!(composer.draft.textarea.text(), "@"); } #[test] @@ -8474,9 +8536,9 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert_eq!(text, format!("{placeholder} src/main.rs ")); - let elements = composer.textarea.text_elements(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str())); @@ -8612,8 +8674,8 @@ mod tests { current_pos += content.len(); } ( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), current_pos, ) }) @@ -8623,19 +8685,22 @@ mod tests { let mut deletion_states = vec![]; // First deletion - composer.textarea.set_cursor(states[0].2); + composer.draft.textarea.set_cursor(states[0].2); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Second deletion - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Verify all states @@ -8673,18 +8738,21 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("{placeholder_base}{placeholder_second}") ); - assert_eq!(composer.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes.len(), 2); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), placeholder_base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder_base); - assert_eq!(composer.pending_pastes[0].1, paste); + assert_eq!(composer.draft.textarea.text(), placeholder_base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.draft.pending_pastes[0].1, paste); } /// Behavior: large-paste placeholder numbering continues when another placeholder of the @@ -8712,21 +8780,24 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), format!("{base}{second}")); + assert_eq!(composer.draft.textarea.text(), format!("{base}{second}")); - composer.textarea.set_cursor(base.len()); + composer.draft.textarea.set_cursor(base.len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), second); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, second); + assert_eq!(composer.draft.textarea.text(), second); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, second); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), format!("{second}{third}")); - assert_eq!(composer.pending_pastes.len(), 2); - assert_eq!(composer.pending_pastes[0].0, second); - assert_eq!(composer.pending_pastes[1].0, third); + assert_eq!(composer.draft.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.draft.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes[0].0, second); + assert_eq!(composer.draft.pending_pastes[1].0, third); } /// Behavior: if all placeholders of a given length are removed, numbering resets to the @@ -8751,18 +8822,21 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -8795,14 +8869,15 @@ mod tests { .map(|pos_from_end| { composer.handle_paste(paste.clone()); composer + .draft .textarea .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( - composer.textarea.text().contains(&placeholder), - composer.pending_pastes.len(), + composer.draft.textarea.text().contains(&placeholder), + composer.draft.pending_pastes.len(), ); - composer.textarea.set_text_clearing_elements(""); + composer.draft.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -8978,23 +9053,35 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9023,23 +9110,26 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9069,13 +9159,13 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -9089,15 +9179,18 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("one\ntwo"); - composer.textarea.set_cursor(/*pos*/ 0); + composer + .draft + .textarea + .set_text_clearing_elements("one\ntwo"); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), "one\n".len()); + assert_eq!(composer.draft.textarea.cursor(), "one\n".len()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 0); + assert_eq!(composer.draft.textarea.cursor(), 0); } #[test] @@ -9125,11 +9218,11 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.text(), "second"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert_eq!(composer.current_text(), ""); } @@ -9148,18 +9241,18 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_operator_pending()); + assert!(composer.draft.textarea.is_vim_operator_pending()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), "hello"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert!(!composer.textarea.is_vim_operator_pending()); + assert!(!composer.draft.textarea.is_vim_operator_pending()); } #[test] @@ -9187,10 +9280,10 @@ mod tests { composer.set_keymap_bindings(&keymap); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); } #[test] @@ -9223,7 +9316,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); @@ -9256,12 +9349,12 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - assert_eq!(composer.textarea.cursor(), "git".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "git".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); } #[test] @@ -9430,11 +9523,15 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/unknown "); - composer.textarea.set_cursor("/unknown ".len()); + composer + .draft + .textarea + .set_text_clearing_elements("/unknown "); + composer.draft.textarea.set_cursor("/unknown ".len()); let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); composer.handle_paste(large_content.clone()); let placeholder = composer + .draft .pending_pastes .first() .expect("expected pending paste") @@ -9444,11 +9541,14 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!( + composer.draft.textarea.text(), + format!("/unknown {placeholder}") + ); - composer.textarea.set_cursor(/*pos*/ 0); - composer.textarea.insert_str(" "); + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str(" "); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -9461,7 +9561,7 @@ mod tests { } _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -9519,7 +9619,7 @@ mod tests { composer.handle_paste(" ".into()); composer.attach_image(path); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert!(text.contains("[Image #1]")); assert!(text.contains("[Image #2]")); assert_eq!( @@ -9549,21 +9649,22 @@ mod tests { // Case 1: backspace at end composer + .draft .textarea .move_cursor_to_end_of_line(/*move_down_at_eol*/ false); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder)); + assert!(!composer.draft.textarea.text().contains(&placeholder)); assert!(composer.attachments.local_images.is_empty()); // Re-add and ensure backspace at element start does not delete the placeholder. composer.attach_image(path); let placeholder2 = composer.attachments.local_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder - if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + if let Some(start_pos) = composer.draft.textarea.text().find(&placeholder2) { let mid_pos = start_pos + (placeholder2.len() / 2); - composer.textarea.set_cursor(mid_pos); + composer.draft.textarea.set_cursor(mid_pos); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().contains(&placeholder2)); + assert!(composer.draft.textarea.text().contains(&placeholder2)); assert_eq!(composer.attachments.local_images.len(), 1); } else { panic!("Placeholder not found in textarea"); @@ -9590,14 +9691,14 @@ mod tests { let path = PathBuf::from("/tmp/image_multibyte.png"); composer.attach_image(path); // Add multibyte text after the placeholder - composer.textarea.insert_str("日本語"); + composer.draft.textarea.insert_str("日本語"); // Cursor is at end; pressing backspace should delete the last character // without panicking and leave the placeholder intact. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(composer.attachments.local_images.len(), 1); - assert!(composer.textarea.text().starts_with("[Image #1]")); + assert!(composer.draft.textarea.text().starts_with("[Image #1]")); } #[test] @@ -9622,15 +9723,15 @@ mod tests { let placeholder1 = composer.attachments.local_images[0].placeholder.clone(); let placeholder2 = composer.attachments.local_images[1].placeholder.clone(); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); let start1 = text.find(&placeholder1).expect("first placeholder present"); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); // Backspace should delete the first placeholder and its mapping. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - let new_text = composer.textarea.text().to_string(); + let new_text = composer.draft.textarea.text().to_string(); assert_eq!( 1, new_text.matches(&placeholder1).count(), @@ -9701,12 +9802,12 @@ mod tests { composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("Test {placeholder1} test ") ); assert_eq!( @@ -9741,11 +9842,11 @@ mod tests { // Insert two adjacent atomic elements. composer.attach_image(path1); composer.attach_image(path2.clone()); - assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1][Image #2]"); assert_eq!(composer.attachments.local_images.len(), 2); // Delete the first element using normal textarea editing (forward Delete at cursor start). - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); // Remaining image should be renumbered and the textarea element updated. @@ -9755,7 +9856,7 @@ mod tests { composer.attachments.local_images[0].placeholder, "[Image #1]" ); - assert_eq!(composer.textarea.text(), "[Image #1]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1]"); } #[test] @@ -9778,7 +9879,7 @@ mod tests { let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[Image #1] ")); + assert!(composer.draft.textarea.text().starts_with("[Image #1] ")); let imgs = composer.take_recent_submission_images(); assert_eq!(imgs, vec![tmp_path]); @@ -9801,6 +9902,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements("/Users/example/project/src/main.rs"); @@ -9812,7 +9914,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9837,6 +9939,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements(" /this-looks-like-a-command"); @@ -9848,7 +9951,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9876,12 +9979,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); let flushed = composer.flush_paste_burst_if_due(); assert!(flushed, "expected pending first char to flush"); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert!(!composer.is_in_paste_burst()); } @@ -9916,22 +10019,22 @@ mod tests { "expected active paste burst during fast typing" ); assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should not appear during burst" ); now += step; } assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should remain empty until flush" ); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected buffered text to flush after stop"); - assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert_eq!(composer.draft.textarea.text(), "a".repeat(count)); assert!( - composer.pending_pastes.is_empty(), + composer.draft.pending_pastes.is_empty(), "no placeholder for small burst" ); } @@ -9962,17 +10065,17 @@ mod tests { } // Nothing should appear until we stop and flush - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected flush after stopping fast input"); let expected_placeholder = format!("[Pasted Content {count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1.len(), count); - assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + assert_eq!(composer.draft.textarea.text(), expected_placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.draft.pending_pastes[0].1.len(), count); + assert!(composer.draft.pending_pastes[0].1.chars().all(|c| c == 'x')); } /// Behavior: human-like typing (with delays between chars) should not be classified as a paste @@ -9993,8 +10096,8 @@ mod tests { let chars: Vec = vec!['z'; count]; type_chars_humanlike(&mut composer, &chars); - assert_eq!(composer.textarea.text(), "z".repeat(count)); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "z".repeat(count)); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -10172,12 +10275,13 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), }); composer + .draft .pending_pastes .push(("[Pasted]".to_string(), "data".to_string())); @@ -10187,13 +10291,16 @@ mod tests { composer.current_text(), format!("Edited {placeholder} text") ); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); assert_eq!(composer.attachments.local_images.len(), 1); assert_eq!( composer.attachments.local_images[0].placeholder, placeholder ); - assert_eq!(composer.textarea.cursor(), composer.current_text().len()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.current_text().len() + ); } #[test] @@ -10211,8 +10318,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10231,8 +10338,8 @@ mod tests { composer.apply_external_edit("git status".to_string()); - assert!(!composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "git status"); } @@ -10251,8 +10358,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10269,7 +10376,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10309,7 +10416,10 @@ mod tests { placeholder1 ); assert_eq!(composer.local_image_paths(), vec![second_path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder1]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder1] + ); } #[test] @@ -10325,8 +10435,9 @@ mod tests { ); let placeholder = "[Pasted Content 5 chars]".to_string(); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer + .draft .pending_pastes .push((placeholder.clone(), "hello".to_string())); @@ -10377,7 +10488,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10512,7 +10623,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.attach_image(PathBuf::from("/tmp/local.png")); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs new file mode 100644 index 0000000000..f739f10ec9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -0,0 +1,45 @@ +//! Editable composer draft state kept separate from composer control flow. + +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::paste_burst::PasteBurst; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; + +pub(super) struct DraftState { + pub(super) textarea: TextArea, + pub(super) textarea_state: RefCell, + pub(super) is_bash_mode: bool, + pub(super) pending_pastes: Vec<(String, String)>, + pub(super) input_enabled: bool, + pub(super) input_disabled_placeholder: Option, + pub(super) paste_burst: PasteBurst, + pub(super) disable_paste_burst: bool, + pub(super) mention_bindings: HashMap, + pub(super) recent_submission_mention_bindings: Vec, +} + +impl DraftState { + pub(super) fn new() -> Self { + Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + is_bash_mode: false, + pending_pastes: Vec::new(), + input_enabled: true, + input_disabled_placeholder: None, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + mention_bindings: HashMap::new(), + recent_submission_mention_bindings: Vec::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ComposerMentionBinding { + pub(super) mention: String, + pub(super) path: String, +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs new file mode 100644 index 0000000000..113385ff72 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -0,0 +1,73 @@ +//! Footer and status-row presentation state for the chat composer. + +use std::time::Instant; + +use ratatui::text::Line; + +use crate::bottom_pane::footer::CollaborationModeIndicator; +use crate::bottom_pane::footer::FooterMode; +use crate::bottom_pane::footer::GoalStatusIndicator; +use crate::key_hint::KeyBinding; +#[cfg(test)] +use std::time::Duration; + +pub(super) struct FooterState { + pub(super) quit_shortcut_expires_at: Option, + pub(super) quit_shortcut_key: KeyBinding, + pub(super) esc_backtrack_hint: bool, + pub(super) use_shift_enter_hint: bool, + pub(super) mode: FooterMode, + pub(super) hint_override: Option>, + pub(super) plan_mode_nudge_visible: bool, + pub(super) flash: Option, + pub(super) context_window_percent: Option, + pub(super) context_window_used_tokens: Option, + pub(super) collaboration_mode_indicator: Option, + pub(super) goal_status_indicator: Option, + pub(super) ide_context_active: bool, + pub(super) status_line_value: Option>, + pub(super) status_line_hyperlink_url: Option, + pub(super) status_line_enabled: bool, + pub(super) side_conversation_context_label: Option, + pub(super) active_agent_label: Option, + pub(super) external_editor_key: Option, + pub(super) show_transcript_key: Option, + pub(super) insert_newline_key: Option, + pub(super) queue_key: Option, + pub(super) toggle_shortcuts_key: Option, + pub(super) history_search_key: Option, + pub(super) reasoning_down_key: Option, + pub(super) reasoning_up_key: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct FooterFlash { + pub(super) line: Line<'static>, + pub(super) expires_at: Instant, +} + +impl FooterState { + pub(super) fn flash_visible(&self) -> bool { + self.flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + + #[cfg(test)] + pub(super) fn show_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.flash = Some(FooterFlash { line, expires_at }); + } + + #[cfg(test)] + pub(super) fn status_line_text(&self) -> Option { + self.status_line_value.as_ref().map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index d5c1ade2a5..3c2663a659 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -102,10 +102,10 @@ impl ChatComposer { /// from replacing an empty composer with the latest prompt before the user has searched for /// anything. pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); if self.popups.current_file_query.is_some() { self.app_event_tx @@ -185,7 +185,7 @@ impl ChatComposer { { self.history_search = None; self.history.reset_search(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.move_cursor_to_end(); } (InputResult::None, true) @@ -296,7 +296,7 @@ impl ChatComposer { return false; }; self.history.reset_navigation(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.restore_draft(search.original_draft); true } @@ -385,7 +385,7 @@ impl ChatComposer { if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { return Vec::new(); } - Self::case_insensitive_match_ranges(self.textarea.text(), &search.query) + Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query) } fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { @@ -520,7 +520,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } @@ -558,18 +558,21 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); for ch in ['g', 'i', 't'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -593,8 +596,8 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.textarea.cursor(), "git status".len() - 1); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.draft.textarea.cursor(), "git status".len() - 1); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } @@ -618,13 +621,19 @@ mod tests { for ch in ['b', 'u', 'g'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); for _ in 0..3 { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); assert!( composer .history_search @@ -635,7 +644,10 @@ mod tests { for _ in 0..3 { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); assert!( composer .history_search @@ -776,17 +788,17 @@ mod tests { .history .record_local_submission(HistoryEntry::new("remembered command".to_string())); composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 2); + composer.draft.textarea.set_cursor(/*pos*/ 2); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "remembered command"); + assert_eq!(composer.draft.textarea.text(), "remembered command"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.textarea.cursor(), 2); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); } #[test] @@ -805,13 +817,13 @@ mod tests { .history .record_local_submission(HistoryEntry::new("remembered command".to_string())); composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 2); + composer.draft.textarea.set_cursor(/*pos*/ 2); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "remembered command"); + assert_eq!(composer.draft.textarea.text(), "remembered command"); composer } @@ -824,8 +836,8 @@ mod tests { let _ = composer.handle_key_event(cancel_key); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.textarea.cursor(), 2); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); } } @@ -843,18 +855,18 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); assert!(!composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); } #[test] @@ -881,18 +893,18 @@ mod tests { now += Duration::from_millis(1); } assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); assert!(!composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), "paste"); + assert_eq!(composer.draft.textarea.text(), "paste"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "paste"); + assert_eq!(composer.draft.textarea.text(), "paste"); } #[test] @@ -918,14 +930,14 @@ mod tests { for ch in ['m', 'a', 't', 'c', 'h'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "oldest matching entry"); + assert_eq!(composer.draft.textarea.text(), "oldest matching entry"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "newest entry"); + assert_eq!(composer.draft.textarea.text(), "newest entry"); } #[test] @@ -950,7 +962,7 @@ mod tests { } assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6d99c0bce6..165169c456 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -808,18 +808,20 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { + self.composer.draft_snapshot() + } + + #[cfg(test)] pub(crate) fn composer_text_elements(&self) -> Vec { self.composer.text_elements() } + #[cfg(test)] pub(crate) fn composer_local_images(&self) -> Vec { self.composer.local_images() } - pub(crate) fn composer_mention_bindings(&self) -> Vec { - self.composer.mention_bindings() - } - #[cfg(test)] pub(crate) fn composer_local_image_paths(&self) -> Vec { self.composer.local_image_paths() @@ -865,6 +867,7 @@ impl BottomPane { self.request_redraw(); } + #[cfg(test)] pub(crate) fn remote_image_urls(&self) -> Vec { self.composer.remote_image_urls() } diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index ca15f1cdf7..b7e8ee72e0 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -152,12 +152,13 @@ impl ChatWidget { return None; } + let composer = self.bottom_pane.composer_draft_snapshot(); let existing_message = UserMessage { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), + text: composer.text, + text_elements: composer.text_elements, + local_images: composer.local_images, + remote_image_urls: composer.remote_image_urls, + mention_bindings: composer.mention_bindings, }; let rejected_messages = self @@ -236,13 +237,14 @@ impl ChatWidget { } pub(crate) fn capture_thread_input_state(&self) -> Option { + let draft = self.bottom_pane.composer_draft_snapshot(); let composer = ThreadComposerState { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - pending_pastes: self.bottom_pane.composer_pending_pastes(), + text: draft.text, + text_elements: draft.text_elements, + local_images: draft.local_images, + remote_image_urls: draft.remote_image_urls, + mention_bindings: draft.mention_bindings, + pending_pastes: draft.pending_pastes, }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), From 9facdccb374660effd99e26f6edbe1ca42bc656f Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Fri, 15 May 2026 10:07:54 -0700 Subject: [PATCH 012/114] Ignore configured hooks in git helpers (#22843) ## What - Internal Git helper commands now ignore configured hook directories during repository bookkeeping. ## Why - These helper flows should stay consistent even when a repository has hook-directory configuration of its own. ## How - Pass a command-local `core.hooksPath` override in the shared helper path and the Git-info helper path. - Add regressions for the baseline index rewrite flow and the metadata status flow. ## Validation - `cargo fmt --manifest-path /Users/bookholt/code/codex/codex-rs/Cargo.toml --all --check` - `cargo test --manifest-path /Users/bookholt/code/codex/codex-rs/Cargo.toml -p codex-git-utils` - `cargo test --manifest-path /Users/bookholt/code/codex/codex-rs/Cargo.toml -p codex-core test_get_has_changes_` --- codex-rs/core/src/git_info_tests.rs | 44 +++++++++++++++++++++++++++ codex-rs/git-utils/src/baseline.rs | 45 ++++++++++++++++++++++++++++ codex-rs/git-utils/src/info.rs | 3 ++ codex-rs/git-utils/src/operations.rs | 9 +++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index b13db36d1e..af62816f72 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -379,6 +379,50 @@ async fn test_get_has_changes_ignores_repo_fsmonitor_config() { ); } +#[cfg(unix)] +#[tokio::test] +async fn test_get_has_changes_ignores_configured_hooks_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + let hooks_dir = repo_path.join(".git/hooks-path-test"); + let hook_path = hooks_dir.join("post-index-change"); + let marker_path = repo_path.join("hook-ran"); + + fs::create_dir_all(&hooks_dir).expect("create hook dir"); + fs::write( + &hook_path, + format!( + "#!/bin/sh\nprintf ran > \"{}\"\n", + marker_path.to_string_lossy() + ), + ) + .expect("write post-index-change hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("read hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("mark hook executable"); + + Command::new("git") + .args([ + "config", + "core.hooksPath", + hooks_dir.to_string_lossy().as_ref(), + ]) + .current_dir(&repo_path) + .output() + .await + .expect("configure hooks path"); + + fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file"); + + assert_eq!(get_has_changes(&repo_path).await, Some(false)); + assert!( + !marker_path.exists(), + "metadata collection should not invoke configured hook directories" + ); +} + #[tokio::test] async fn test_get_git_working_tree_state_clean_repo() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); diff --git a/codex-rs/git-utils/src/baseline.rs b/codex-rs/git-utils/src/baseline.rs index c63b894049..083dd6010f 100644 --- a/codex-rs/git-utils/src/baseline.rs +++ b/codex-rs/git-utils/src/baseline.rs @@ -584,6 +584,51 @@ mod tests { assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n"); } + #[cfg(unix)] + #[tokio::test] + async fn write_index_ignores_configured_hooks_path() { + use std::os::unix::fs::PermissionsExt; + + let home = TempDir::new().expect("tempdir"); + let root = home.path().join("repo"); + let hooks_dir = root.join(".git/hooks-path-test"); + let marker_path = root.join("hook-ran"); + let hook_path = hooks_dir.join("post-index-change"); + + fs::create_dir_all(&root).expect("create root"); + fs::write(root.join("MEMORY.md"), "baseline").expect("write memory"); + reset_git_repository(&root).await.expect("reset repo"); + fs::create_dir_all(&hooks_dir).expect("create hook dir"); + fs::write( + &hook_path, + format!( + "#!/bin/sh\nprintf ran > \"{}\"\n", + marker_path.to_string_lossy() + ), + ) + .expect("write post-index-change hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("read hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("mark hook executable"); + git_stdout( + &root, + &[ + "config", + "core.hooksPath", + hooks_dir.to_string_lossy().as_ref(), + ], + ); + + write_index_from_head(&root).expect("rewrite baseline index"); + + assert!( + !marker_path.exists(), + "baseline index writes should not invoke configured hook directories" + ); + } + #[tokio::test] async fn diff_reports_added_modified_and_deleted_files() { let home = TempDir::new().expect("tempdir"); diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 8c7c4046d4..722d556b37 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -40,6 +40,7 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option { /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); +const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct GitInfo { @@ -375,6 +376,8 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option Result<(), GitToolingError> { match run_git_for_stdout( path, @@ -98,7 +100,12 @@ where { let iterator = args.into_iter(); let (lower, upper) = iterator.size_hint(); - let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower)); + let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower) + 2); + // Keep internal Git helper commands independent of configured hook directories. + args_vec.push(OsString::from("-c")); + args_vec.push(OsString::from(format!( + "core.hooksPath={DISABLED_HOOKS_PATH}" + ))); for arg in iterator { args_vec.push(OsString::from(arg.as_ref())); } From aeca1cba6f3018b7c531bc698c06d8348cee4698 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 10:11:16 -0700 Subject: [PATCH 013/114] context: remove legacy permissions instructions helper (#22790) ## Why The permissions instruction builder should consume the new permissions model directly. Keeping a `SandboxPolicy` conversion helper in this path encourages new code to route through legacy sandbox policy values even when the caller already has a `PermissionProfile`. ## What Changed - Removed `PermissionsInstructions::from_policy`. - Removed the test that exercised that legacy helper. - Left the existing profile-based instruction coverage in place. ## How To Review Review `codex-rs/core/src/context/permissions_instructions.rs` first. This PR is intentionally narrow: the production behavior should be unchanged for profile callers, and the deleted surface was only a convenience adapter from `SandboxPolicy`. ## Verification - `cargo test -p codex-core builds_permissions_from_profile` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22790). * #22795 * #22792 * #22791 * __->__ #22790 --- .../src/context/permissions_instructions.rs | 22 ------------------ .../context/permissions_instructions_tests.rs | 23 ------------------- 2 files changed, 45 deletions(-) diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index cd5a8ac338..e982ca17d2 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -8,7 +8,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::WritableRoot; use codex_utils_template::Template; use std::path::Path; @@ -85,27 +84,6 @@ impl PermissionsInstructions { ) } - /// Builds permissions instructions from a legacy sandbox policy. - pub fn from_policy( - sandbox_policy: &SandboxPolicy, - approval_policy: AskForApproval, - approvals_reviewer: ApprovalsReviewer, - exec_policy: &Policy, - cwd: &Path, - exec_permission_approvals_enabled: bool, - request_permissions_tool_enabled: bool, - ) -> Self { - Self::from_permission_profile( - &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), - approval_policy, - approvals_reviewer, - exec_policy, - cwd, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - ) - } - fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 16d5dc631a..6d1aa5d886 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -53,29 +53,6 @@ fn builds_permissions_with_network_access_override() { ); } -#[test] -fn builds_permissions_from_policy() { - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let instructions = PermissionsInstructions::from_policy( - &policy, - AskForApproval::UnlessTrusted, - ApprovalsReviewer::User, - &Policy::empty(), - &PathBuf::from("/tmp"), - /*exec_permission_approvals_enabled*/ false, - /*request_permissions_tool_enabled*/ false, - ); - let text = instructions.body(); - assert!(text.contains("Network access is enabled.")); - assert!(text.contains("`approval_policy` is `unless-trusted`")); -} - #[test] fn builds_permissions_from_profile() { let cwd = PathBuf::from("/tmp"); From 4c80435ebad5de8c89813b1c6ccfd78228dff6d2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 10:58:50 -0700 Subject: [PATCH 014/114] telemetry: tag sandboxes from permission profiles (#22791) ## Why Sandbox telemetry tags should be derived from the active permission profile, not from a legacy `SandboxPolicy`, so the tagging code stays aligned with the permissions migration and does not preserve a policy-shaped production helper only for tests. ## What Changed - Removed the production `sandbox_tag(&SandboxPolicy, ...)` helper. - Updated sandbox tag tests to construct the relevant `PermissionProfile` values directly. - Kept the platform-specific sandbox tag behavior under the existing `permission_profile_sandbox_tag` path. ## How To Review The production change is in `codex-rs/core/src/sandbox_tags.rs`. Most of the diff is test cleanup that replaces legacy policy setup with permission profiles, so review the expected tag assertions rather than the old helper mechanics. ## Verification - `cargo test -p codex-core sandbox_tag` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22791). * #22795 * #22792 * __->__ #22791 --- codex-rs/core/src/sandbox_tags.rs | 14 -------------- codex-rs/core/src/sandbox_tags_tests.rs | 20 ++++++++++---------- codex-rs/core/src/turn_metadata_tests.rs | 10 ++++++---- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index f6db4da918..2973a5bf94 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -1,24 +1,10 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; -#[cfg(test)] -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; use codex_sandboxing::policy_transforms::should_require_platform_sandbox; use std::path::Path; -#[cfg(test)] -pub(crate) fn sandbox_tag( - policy: &SandboxPolicy, - windows_sandbox_level: WindowsSandboxLevel, -) -> &'static str { - permission_profile_sandbox_tag( - &PermissionProfile::from_legacy_sandbox_policy(policy), - windows_sandbox_level, - /*enforce_managed_network*/ false, - ) -} - pub(crate) fn permission_profile_sandbox_tag( profile: &PermissionProfile, windows_sandbox_level: WindowsSandboxLevel, diff --git a/codex-rs/core/src/sandbox_tags_tests.rs b/codex-rs/core/src/sandbox_tags_tests.rs index 8b00de9ccd..64dc50574f 100644 --- a/codex-rs/core/src/sandbox_tags_tests.rs +++ b/codex-rs/core/src/sandbox_tags_tests.rs @@ -1,6 +1,5 @@ use super::permission_profile_policy_tag; use super::permission_profile_sandbox_tag; -use super::sandbox_tag; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -10,8 +9,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; use codex_utils_absolute_path::AbsolutePathBuf; @@ -20,29 +17,32 @@ use std::path::Path; #[test] fn danger_full_access_is_untagged_even_when_linux_sandbox_defaults_apply() { - let actual = sandbox_tag( - &SandboxPolicy::DangerFullAccess, + let actual = permission_profile_sandbox_tag( + &PermissionProfile::Disabled, WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); assert_eq!(actual, "none"); } #[test] fn external_sandbox_keeps_external_tag_when_linux_sandbox_defaults_apply() { - let actual = sandbox_tag( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, + let actual = permission_profile_sandbox_tag( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, }, WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); assert_eq!(actual, "external"); } #[test] fn default_linux_sandbox_uses_platform_sandbox_tag() { - let actual = sandbox_tag( - &SandboxPolicy::new_read_only_policy(), + let actual = permission_profile_sandbox_tag( + &PermissionProfile::read_only(), WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); let expected = get_platform_sandbox(/*windows_sandbox_enabled*/ false) .map(SandboxType::as_metric_tag) diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index a9c7a7b87d..2f79927103 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -1,9 +1,8 @@ use super::*; -use crate::sandbox_tags::sandbox_tag; +use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::ThreadSource; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -89,7 +88,6 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { fn turn_metadata_state_uses_platform_sandbox_tag() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let permission_profile = PermissionProfile::read_only(); let state = TurnMetadataState::new( @@ -110,7 +108,11 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); - let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); + let expected_sandbox = permission_profile_sandbox_tag( + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); assert_eq!(thread_id, Some("thread-a")); From 991bc308ab4f281159884382dba447632e56ce18 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 11:46:41 -0700 Subject: [PATCH 015/114] Wait for subagent hook logs in test --- .../tests/suite/subagent_notifications.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a91149b46a..c74269e585 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -180,6 +180,27 @@ fn read_hook_log(home: &Path, filename: &str) -> Result> .collect() } +async fn wait_for_hook_log( + home: &Path, + filename: &str, + expected_len: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let inputs = read_hook_log(home, filename)?; + if inputs.len() >= expected_len { + return Ok(inputs); + } + if Instant::now() >= deadline { + anyhow::bail!( + "expected at least {expected_len} entries in {filename}, got {}", + inputs.len() + ); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -418,7 +439,8 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let child_requests = wait_for_requests(&child_request_log).await?; assert_eq!(child_requests.len(), 1); - let start_inputs = read_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl")?; + let start_inputs = + wait_for_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl", 1).await?; assert_eq!(start_inputs.len(), 1); assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); let spawned_id = wait_for_spawned_thread_id(&test).await?; @@ -428,7 +450,7 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( ); let session_start_inputs = - read_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl")?; + wait_for_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl", 1).await?; assert_eq!(session_start_inputs.len(), 1); assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); assert_ne!( From c15613f2b6deef3f58634e7ec51555ae4ed19dd2 Mon Sep 17 00:00:00 2001 From: Boyang Niu Date: Fri, 15 May 2026 11:52:14 -0700 Subject: [PATCH 016/114] Forward apps MCP product SKU from Codex config (#22872) This adds `apps_mcp_product_sku` as a toplevel config.toml key. We pass the given value as a header when listing MCPs for the client, allowing connectors to be filtered per product entry point. --------- Co-authored-by: Codex --- codex-rs/codex-mcp/src/mcp/mod.rs | 7 +++- codex-rs/codex-mcp/src/mcp/mod_tests.rs | 35 +++++++++++++++++++ codex-rs/config/src/config_toml.rs | 3 ++ codex-rs/config/src/loader/mod.rs | 1 + codex-rs/core/config.schema.json | 4 +++ .../core/src/config/config_loader_tests.rs | 2 ++ codex-rs/core/src/config/config_tests.rs | 27 ++++++++++++++ codex-rs/core/src/config/mod.rs | 5 +++ codex-rs/thread-manager-sample/src/main.rs | 1 + 9 files changed, 84 insertions(+), 1 deletion(-) diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 0d24718ba9..2ce03c9317 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -109,6 +109,8 @@ pub struct McpConfig { pub chatgpt_base_url: String, /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. @@ -427,12 +429,15 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { let url = codex_apps_mcp_url(config); + let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| { + HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())]) + }); McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), - http_headers: None, + http_headers, env_http_headers: None, }, experimental_environment: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 286c191b29..99622d7f07 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -18,6 +18,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), mcp_oauth_callback_port: None, @@ -251,6 +252,40 @@ fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); } +#[test] +fn codex_apps_server_config_forwards_configured_product_sku_header() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_product_sku = Some("tpp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + + match &config.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([( + "X-OpenAI-Product-Sku".to_string(), + "tpp".to_string(), + )])) + ); + assert!(env_http_headers.is_none()); + } + other => panic!("expected streamable http transport, got {other:?}"), + } +} + #[tokio::test] async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 72641150dd..76ba338fc0 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -360,6 +360,9 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. + pub apps_mcp_product_sku: Option, + /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 89a35afebe..49df306abb 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -61,6 +61,7 @@ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0387b2e401..de3632d629 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4016,6 +4016,10 @@ "default": null, "description": "Settings for app-specific controls." }, + "apps_mcp_product_sku": { + "description": "Optional product SKU forwarded on host-owned Codex Apps MCP requests.", + "type": "string" + }, "audio": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index b5c5c2ec18..99da65b7c7 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -2353,6 +2353,7 @@ model = "project-model" model_instructions_file = "instructions.md" openai_base_url = "https://attacker.example/v1" chatgpt_base_url = "https://attacker.example/backend-api" +apps_mcp_product_sku = "attacker" model_provider = "attacker" notify = ["sh", "-c", "echo attacker"] profile = "attacker" @@ -2404,6 +2405,7 @@ wire_api = "responses" let ignored_project_config_keys = vec![ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f197ccad71..7a4a3a5c7b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4877,12 +4877,14 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_mcp_product_sku = Some("tpp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); assert_eq!( mcp_config.apps_mcp_path_override.as_deref(), Some("/custom/mcp") ); + assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp")); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; @@ -7692,6 +7694,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -8142,6 +8145,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -8306,6 +8310,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -8455,6 +8460,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -9177,6 +9183,27 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" +apps_mcp_product_sku = "tpp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp")); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 527c93b3f3..bad23da221 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -871,6 +871,9 @@ pub struct Config { /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -1294,6 +1297,7 @@ impl Config { McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), apps_mcp_path_override: self.apps_mcp_path_override.clone(), + apps_mcp_product_sku: self.apps_mcp_product_sku.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, mcp_oauth_callback_port: self.mcp_oauth_callback_port, @@ -3419,6 +3423,7 @@ impl Config { .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), apps_mcp_path_override, + apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 313971afdc..8a168bad13 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -246,6 +246,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, From 6afe00efda39d396ddc6703085ebfea7249889c8 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 15 May 2026 12:41:18 -0700 Subject: [PATCH 017/114] Workflow updates (#22582) --- .github/workflows/issue-deduplicator.yml | 70 ++++++----- .github/workflows/issue-labeler.yml | 151 +++++++++++++++++++++++ 2 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/issue-labeler.yml diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 11b4e914fe..f15c190102 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -15,14 +15,8 @@ jobs: permissions: contents: read outputs: - issues_json: ${{ steps.normalize-all.outputs.issues_json }} - reason: ${{ steps.normalize-all.outputs.reason }} - has_matches: ${{ steps.normalize-all.outputs.has_matches }} + codex_output: ${{ steps.codex-all.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -67,6 +61,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -100,10 +96,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-all: + name: Normalize pass 1 output + needs: gather-duplicates-all + if: ${{ needs.gather-duplicates-all.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-all.outputs.issues_json }} + reason: ${{ steps.normalize-all.outputs.reason }} + has_matches: ${{ steps.normalize-all.outputs.has_matches }} + steps: - id: normalize-all name: Normalize pass 1 output env: - CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-all.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -146,21 +153,15 @@ jobs: gather-duplicates-open: name: Identify potential duplicates (open issues fallback) - # Pass 1 may drop sudo on the runner, so run the fallback in a fresh job. - needs: gather-duplicates-all - if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }} + # Pass 1 Codex execution drops sudo on its runner, so run the fallback in a fresh job. + needs: normalize-duplicates-all + if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }} runs-on: ubuntu-latest permissions: contents: read outputs: - issues_json: ${{ steps.normalize-open.outputs.issues_json }} - reason: ${{ steps.normalize-open.outputs.reason }} - has_matches: ${{ steps.normalize-open.outputs.has_matches }} + codex_output: ${{ steps.codex-open.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -203,6 +204,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -236,10 +239,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-open: + name: Normalize pass 2 output + needs: gather-duplicates-open + if: ${{ needs.gather-duplicates-open.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-open.outputs.issues_json }} + reason: ${{ steps.normalize-open.outputs.reason }} + has_matches: ${{ steps.normalize-open.outputs.has_matches }} + steps: - id: normalize-open name: Normalize pass 2 output env: - CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-open.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -283,9 +297,9 @@ jobs: select-final: name: Select final duplicate set needs: - - gather-duplicates-all - - gather-duplicates-open - if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }} + - normalize-duplicates-all + - normalize-duplicates-open + if: ${{ always() && needs.normalize-duplicates-all.result == 'success' && (needs.normalize-duplicates-open.result == 'success' || needs.normalize-duplicates-open.result == 'skipped') }} runs-on: ubuntu-latest permissions: contents: read @@ -295,12 +309,12 @@ jobs: - id: select-final name: Select final duplicate set env: - PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }} - PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }} - PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }} - PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }} - PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }} - PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }} + PASS1_ISSUES: ${{ needs.normalize-duplicates-all.outputs.issues_json }} + PASS1_REASON: ${{ needs.normalize-duplicates-all.outputs.reason }} + PASS2_ISSUES: ${{ needs.normalize-duplicates-open.outputs.issues_json }} + PASS2_REASON: ${{ needs.normalize-duplicates-open.outputs.reason }} + PASS1_HAS_MATCHES: ${{ needs.normalize-duplicates-all.outputs.has_matches }} + PASS2_HAS_MATCHES: ${{ needs.normalize-duplicates-open.outputs.has_matches }} run: | set -eo pipefail diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 0000000000..77fe5d07c8 --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,151 @@ +name: Issue Labeler + +on: + issues: + types: + - opened + - labeled + +jobs: + gather-labels: + name: Generate label suggestions + # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) + if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + codex_output: ${{ steps.codex.outputs.final-message }} + steps: + - id: codex + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 + with: + openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} + allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only + prompt: | + You are an assistant that reviews GitHub issues for the repository. + + Your job is to choose the most appropriate labels for the issue described later in this prompt. + Follow these rules: + + - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. + 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). + 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. + 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). + + - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. + 1. CLI — the Codex command line interface. + 2. extension — VS Code (or other IDE) extension-specific issues. + 3. app - Issues related to the Codex desktop application. + 4. codex-web — Issues targeting the Codex web UI/Cloud experience. + 5. github-action — Issues with the Codex GitHub action. + 6. iOS — Issues with the Codex iOS app. + + - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. + - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks. + 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). + 2. mcp — Topics involving Model Context Protocol servers/clients. + 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. + 4. azure — Problems or requests tied to Azure OpenAI deployments. + 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 6. code-review — Issues related to the code review feature or functionality. + 7. safety-check - Issues related to cyber risk detection or trusted access verification. + 8. auth - Problems related to authentication, login, or access tokens. + 9. exec - Problems related to the "codex exec" command or functionality. + 10. hooks - Problems related to event hooks + 11. context - Problems related to compaction, context windows, or available context reporting. + 12. skills - Problems related to skills or plugins + 13. custom-model - Problems that involve using custom model providers, local models, or OSS models. + 14. rate-limits - Problems related to token limits, rate limits, or token usage reporting. + 15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. + 16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. + 17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. + 18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior. + 19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior. + 20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration. + 21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state. + 22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior. + 23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior. + 24. computer-use - Issues involving agentic computer use or SkyComputerUseService. + 25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app. + 26. memory - Issues involving agentic memory storage and retrieval. + 27. imagen - Issues involving image generation. + 28. remote - Issues involving remote access, remote control, or SSH. + 29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks. + 30. automations - Issues involving scheduled automation tasks or heartbeats. + 31. pets - Issues involving pets avatars and animations. + 32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets. + + Issue number: ${{ github.event.issue.number }} + + Issue title: + ${{ github.event.issue.title }} + + Issue body: + ${{ github.event.issue.body }} + + Repository full name: + ${{ github.repository }} + + output-schema: | + { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["labels"], + "additionalProperties": false + } + + apply-labels: + name: Apply labels from Codex output + needs: gather-labels + if: ${{ needs.gather-labels.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} + steps: + - name: Apply labels + run: | + json=${CODEX_OUTPUT//$'\r'/} + if [ -z "$json" ]; then + echo "Codex produced no output. Skipping label application." + exit 0 + fi + + if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then + echo "Codex output did not include a labels array. Raw output: $json" + exit 0 + fi + + labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring') + if [ -z "$labels" ]; then + echo "Codex returned an empty array. Nothing to do." + exit 0 + fi + + cmd=(gh issue edit "$ISSUE_NUMBER") + while IFS= read -r label; do + cmd+=(--add-label "$label") + done <<< "$labels" + + "${cmd[@]}" || true + + - name: Remove codex-label trigger + if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} + run: | + gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true + echo "Attempted to remove label: codex-label" From 83bbb4f32660c9246e90da92e664e2e69401c07b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 12:45:48 -0700 Subject: [PATCH 018/114] app-server: stop returning thread permission profiles (#22792) ## Why The app-server thread lifecycle API should no longer expose the full `PermissionProfile` value. After the permissions-profile migration, clients should round-trip only the active profile identity through `activePermissionProfile` and `permissions` when that identity is known. The full profile is server-side config. Treating a response-derived legacy sandbox projection as a new local profile can lose named-profile restrictions and accidentally widen permissions on the next turn. The legacy `sandbox` response field remains only as the compatibility/display fallback. ## What Changed - Removed `permissionProfile` from `ThreadStartResponse`, `ThreadResumeResponse`, and `ThreadForkResponse`. - Stopped populating that field in app-server thread start/resume/fork responses. - Updated embedded exec/TUI response mapping to derive display permission state from local config or the legacy sandbox fallback instead of a response profile value. - Added a TUI turn override shape that distinguishes preserving server permissions, selecting an active profile id, and sending a legacy sandbox for an explicit local override. - Preserved remote app-server permissions across turns by sending `permissions` only when an `activePermissionProfile` id is known, and otherwise sending no sandbox override unless the user selected a local override. - Kept embedded `thread/resume` hydration server-authored when `activePermissionProfile` is absent, which matches the live-thread attach path where the server ignores requested overrides. - Updated the app-server README to remove the obsolete lifecycle response `permissionProfile` reference. The remaining `permissionProfile` README references are request-side permission overrides. - Regenerated app-server JSON schema and TypeScript fixtures. - Kept the generated typed response enum exempt from `large_enum_variant`, matching the existing payload enum exemption after the lifecycle response variants shrank. ## How To Review Start with `codex-rs/app-server-protocol/src/protocol/v2/thread.rs` to confirm the response shape, then check the response construction in `codex-rs/app-server/src/request_processors`. The generated schema and TypeScript fixture changes are mechanical follow-through from the protocol removal. The TUI behavior is the delicate part: review `codex-rs/tui/src/app_server_session.rs` for response hydration and turn-start override projection, then `codex-rs/tui/src/app/thread_routing.rs` for the decision about whether the next turn should preserve the server snapshot, send an active profile id, or send a legacy sandbox for an explicit local override. ## Verification - `just write-app-server-schema` - `cargo test -p codex-app-server-protocol thread_lifecycle_responses_default_missing_optional_fields` - `cargo test -p codex-exec session_configured_from_thread_response_uses_permission_profile_from_config` - `cargo test -p codex-tui --lib thread_response` - `cargo test -p codex-tui turn_permissions_` - `cargo test -p codex-tui resume_response_restores_turns_from_thread_items` - `cargo test -p codex-analytics track_response_only_enqueues_analytics_relevant_responses` - `just fix -p codex-analytics` - `just fix -p codex-app-server-protocol` - `just fix -p codex-tui` - `just argument-comment-lint` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22792). * #22795 * __->__ #22792 --- .../analytics/src/analytics_client_tests.rs | 2 - codex-rs/analytics/src/client_tests.rs | 9 - .../codex_app_server_protocol.schemas.json | 6 +- .../codex_app_server_protocol.v2.schemas.json | 6 +- .../schema/json/v2/ThreadForkResponse.json | 327 +----------------- .../schema/json/v2/ThreadResumeResponse.json | 327 +----------------- .../schema/json/v2/ThreadStartResponse.json | 327 +----------------- .../typescript/v2/ThreadForkResponse.ts | 3 +- .../typescript/v2/ThreadResumeResponse.ts | 3 +- .../typescript/v2/ThreadStartResponse.ts | 3 +- .../src/protocol/common.rs | 3 +- .../src/protocol/v2/tests.rs | 3 - .../src/protocol/v2/thread.rs | 25 +- codex-rs/app-server/README.md | 2 +- .../request_processors/thread_lifecycle.rs | 1 - .../request_processors/thread_processor.rs | 3 - codex-rs/exec/src/lib.rs | 12 +- codex-rs/exec/src/lib_tests.rs | 11 +- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/thread_routing.rs | 122 ++++++- codex-rs/tui/src/app_server_session.rs | 219 ++++++------ codex-rs/tui/src/session_state.rs | 4 +- 22 files changed, 249 insertions(+), 1170 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7b9ab4f9d6..ed626699d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -206,7 +206,6 @@ fn sample_thread_start_response( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -263,7 +262,6 @@ fn sample_thread_resume_response_with_source( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 885875346d..bfb224d899 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::SessionSource as AppServerSessionSource; @@ -29,7 +28,6 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use std::collections::HashSet; @@ -142,10 +140,6 @@ fn sample_thread(thread_id: &str) -> Thread { } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { thread: sample_thread("thread-1"), @@ -158,7 +152,6 @@ fn sample_thread_start_response() -> ClientResponsePayload { approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -176,7 +169,6 @@ fn sample_thread_resume_response() -> ClientResponsePayload { approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -194,7 +186,6 @@ fn sample_thread_fork_response() -> ClientResponsePayload { approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) 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 8b292f667d..935ec98e88 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 @@ -15527,7 +15527,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -17019,7 +17019,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -17327,7 +17327,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ 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 16e548e8c2..ccf68b28d8 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 @@ -13351,7 +13351,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -14843,7 +14843,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -15151,7 +15151,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ 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 4eb85f4ed3..1608b2f48c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -470,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -904,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2572,7 +2247,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ 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 312d289e41..302c2e1069 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -470,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -904,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2572,7 +2247,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ 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 c363f2e78d..9dc08614cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -470,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -904,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2572,7 +2247,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c44533ec1a..c5b1201c26 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f91756c7c6..7a4f90377c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 9573bd7dee..38859a3805 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e0cd330fff..f7e04b3dc5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -223,6 +223,7 @@ macro_rules! client_request_definitions { /// Typed response from the server to the client. #[derive(Serialize, Deserialize, Debug, Clone)] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientResponse { $( @@ -2301,7 +2302,6 @@ mod tests { approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }, @@ -2348,7 +2348,6 @@ mod tests { "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": null, "activePermissionProfile": null, "reasoningEffort": null } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 50058cb686..d17a3dd988 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3498,9 +3498,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(fork.active_permission_profile, None); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index a3321436f6..ffe2c353d1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,7 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfile; use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; @@ -213,14 +212,8 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/start.activePermissionProfile")] @@ -339,14 +332,8 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/resume.activePermissionProfile")] @@ -459,14 +446,8 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/fork.activePermissionProfile")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 09c450614f..4d21eb2846 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -133,7 +133,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots, `permissionProfile` for the exact active runtime permissions, and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. 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 c64066ee93..4781df8350 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -626,7 +626,6 @@ pub(super) async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile: Some(permission_profile.into()), active_permission_profile, reasoning_effort, }; 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 3038feeef1..21160950ab 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1199,7 +1199,6 @@ impl ThreadRequestProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; @@ -2554,7 +2553,6 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -3215,7 +3213,6 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9f7174ec68..1004124ba5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1089,11 +1089,7 @@ fn session_configured_from_thread_start_response( response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| config.permissions.effective_permission_profile()), + config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, @@ -1114,11 +1110,7 @@ fn session_configured_from_thread_resume_response( response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| config.permissions.effective_permission_profile()), + config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 367bba5d05..92dc0678e5 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -528,7 +528,7 @@ async fn session_configured_from_thread_response_uses_review_policy_from_respons } #[tokio::test] -async fn session_configured_from_thread_response_uses_permission_profile_from_response() { +async fn session_configured_from_thread_response_uses_permission_profile_from_config() { let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() @@ -537,13 +537,15 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re .build() .await .expect("build config"); - let mut response = sample_thread_start_response(); - response.permission_profile = Some(PermissionProfile::Disabled.into()); + let response = sample_thread_start_response(); let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); - assert_eq!(event.permission_profile, PermissionProfile::Disabled); + assert_eq!( + event.permission_profile, + config.permissions.effective_permission_profile() + ); } fn sample_thread_start_response() -> ThreadStartResponse { @@ -583,7 +585,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1f160fa3c3..fcaf4d2290 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; +use crate::app_server_session::TurnPermissionsOverride; use crate::app_server_session::app_server_rate_limit_snapshots; use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 716f2eedf0..4f858f10e6 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -588,14 +588,11 @@ impl App { let config = self.chat_widget.config_ref(); let approvals_reviewer = approvals_reviewer.unwrap_or(config.approvals_reviewer); - let active_permission_profile = - if config.permissions.effective_permission_profile() - == permission_profile.clone() - { - config.permissions.active_permission_profile() - } else { - None - }; + let permissions_override = Self::turn_permissions_override_from_config( + config, + permission_profile, + self.runtime_permission_profile_override.as_ref(), + ); app_server .turn_start( thread_id, @@ -603,8 +600,7 @@ impl App { cwd.clone(), *approval_policy, approvals_reviewer, - permission_profile.clone(), - active_permission_profile, + permissions_override, config.permissions.user_visible_workspace_roots(), model.to_string(), *effort, @@ -700,6 +696,36 @@ impl App { } } + fn turn_permissions_override_from_config( + config: &Config, + permission_profile: &PermissionProfile, + runtime_permission_profile_override: Option<&PermissionProfile>, + ) -> TurnPermissionsOverride { + let effective_permission_profile = config.permissions.effective_permission_profile(); + if &effective_permission_profile == permission_profile + && let Some(active_permission_profile) = config.permissions.active_permission_profile() + { + return TurnPermissionsOverride::ActiveProfile(active_permission_profile); + } + + let runtime_permission_profile_override = + runtime_permission_profile_override.map(|profile| { + profile + .clone() + .materialize_project_roots_with_workspace_roots( + &config.effective_workspace_roots(), + ) + }); + if runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| profile == permission_profile) + { + return TurnPermissionsOverride::LegacySandbox(permission_profile.clone()); + } + + TurnPermissionsOverride::Preserve + } + pub(super) fn handle_skills_list_result( &mut self, result: Result, @@ -1457,3 +1483,79 @@ impl App { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ActivePermissionProfile; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + + async fn config_with_workspace_profile() -> Config { + let temp_dir = tempfile::tempdir().expect("tempdir"); + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build") + } + + #[tokio::test] + async fn turn_permissions_use_active_profile_when_available() { + let config = config_with_workspace_profile().await; + let permission_profile = config.permissions.effective_permission_profile(); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, + &permission_profile, + /*runtime_permission_profile_override*/ None, + ), + TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + )) + ); + } + + #[tokio::test] + async fn turn_permissions_preserve_server_snapshot_without_local_override() { + let mut config = config_with_workspace_profile().await; + config + .permissions + .set_permission_profile(PermissionProfile::read_only()) + .expect("read-only profile should be allowed"); + let permission_profile = config.permissions.effective_permission_profile(); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, + &permission_profile, + /*runtime_permission_profile_override*/ None, + ), + TurnPermissionsOverride::Preserve + ); + } + + #[tokio::test] + async fn turn_permissions_send_legacy_sandbox_for_local_override() { + let mut config = config_with_workspace_profile().await; + let permission_profile = PermissionProfile::workspace_write(); + config + .permissions + .set_permission_profile(permission_profile.clone()) + .expect("workspace profile should be allowed"); + let effective_permission_profile = config.permissions.effective_permission_profile(); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, + &effective_permission_profile, + Some(&permission_profile), + ), + TurnPermissionsOverride::LegacySandbox(effective_permission_profile) + ); + } +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 0470d24db6..db6dc9e9a8 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -172,6 +172,16 @@ pub(crate) struct AppServerStartedThread { pub(crate) turns: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TurnPermissionsOverride { + /// Leave the app-server thread's sticky permission profile unchanged. + Preserve, + /// Select a named or built-in profile by id. + ActiveProfile(ActivePermissionProfile), + /// Apply a user-selected legacy/custom permission profile. + LegacySandbox(PermissionProfile), +} + impl AppServerSession { pub(crate) fn new(client: AppServerClient) -> Self { Self { @@ -548,8 +558,7 @@ impl AppServerSession { cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - permission_profile: PermissionProfile, - active_permission_profile: Option, + permissions_override: TurnPermissionsOverride, workspace_roots: &[AbsolutePathBuf], model: String, effort: Option, @@ -560,13 +569,8 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let (sandbox_policy, permissions) = turn_permissions_overrides( - &permission_profile, - active_permission_profile, - cwd.as_path(), - workspace_roots, - self.thread_params_mode(), - ); + let (sandbox_policy, permissions) = + turn_permissions_overrides(permissions_override, cwd.as_path()); self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -1185,32 +1189,32 @@ fn permissions_selection_from_active_profile( } fn turn_permissions_overrides( - permission_profile: &PermissionProfile, - active_permission_profile: Option, + permissions_override: TurnPermissionsOverride, cwd: &std::path::Path, - _workspace_roots: &[AbsolutePathBuf], - thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { - let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile.map(permissions_selection_from_active_profile) - } else { - None - }; - let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote) - || permissions.is_none()) - .then(|| { - let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd); - let policy = legacy_profile - .to_legacy_sandbox_policy(cwd) - .unwrap_or_else(|err| { - unreachable!("legacy-compatible permissions must project to legacy policy: {err}") - }); - policy.into() - }); - (sandbox_policy, permissions) + match permissions_override { + TurnPermissionsOverride::Preserve => (None, None), + TurnPermissionsOverride::ActiveProfile(active_permission_profile) => ( + None, + Some(permissions_selection_from_active_profile( + active_permission_profile, + )), + ), + TurnPermissionsOverride::LegacySandbox(permission_profile) => { + let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd); + let policy = legacy_profile + .to_legacy_sandbox_policy(cwd) + .unwrap_or_else(|err| { + unreachable!( + "legacy-compatible permissions must project to legacy policy: {err}" + ) + }); + (Some(policy.into()), None) + } + } } fn permissions_selection_from_config( @@ -1413,9 +1417,8 @@ async fn thread_session_state_from_thread_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( + let permission_profile = display_permission_profile_from_thread_response( &response.sandbox, - response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, @@ -1446,13 +1449,21 @@ async fn thread_session_state_from_thread_resume_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( - &response.sandbox, - response.permission_profile.as_ref(), - response.cwd.as_path(), - config, - thread_params_mode, - ); + let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded) + && response.active_permission_profile.is_none() + { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ) + } else { + display_permission_profile_from_thread_response( + &response.sandbox, + response.cwd.as_path(), + config, + thread_params_mode, + ) + }; thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), @@ -1479,9 +1490,8 @@ async fn thread_session_state_from_thread_fork_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( + let permission_profile = display_permission_profile_from_thread_response( &response.sandbox, - response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, @@ -1507,16 +1517,12 @@ async fn thread_session_state_from_thread_fork_response( .await } -fn permission_profile_from_thread_response( +fn display_permission_profile_from_thread_response( sandbox: &codex_app_server_protocol::SandboxPolicy, - permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, cwd: &std::path::Path, config: &Config, thread_params_mode: ThreadParamsMode, ) -> PermissionProfile { - if let Some(permission_profile) = permission_profile { - return permission_profile.clone().into(); - } match thread_params_mode { ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(), ThreadParamsMode::Remote => { @@ -1692,16 +1698,12 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let workspace_roots = vec![cwd.clone()]; let expected_permissions = permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::workspace_write(), - Some(active_permission_profile), + TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - &workspace_roots, - ThreadParamsMode::Embedded, ); assert_eq!(sandbox_policy, None); @@ -1711,17 +1713,12 @@ mod tests { #[test] fn embedded_turn_permissions_select_profile_id_only() { let cwd = test_path_buf("/workspace/project").abs(); - let extra_root = test_path_buf("/workspace/cache").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let workspace_roots = vec![cwd.clone(), extra_root]; let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::workspace_write(), - Some(active_permission_profile), + TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - &workspace_roots, - ThreadParamsMode::Embedded, ); assert_eq!(sandbox_policy, None); @@ -1734,15 +1731,23 @@ mod tests { } #[test] - fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { + fn turn_permissions_preserve_thread_permissions_without_override() { + let cwd = test_path_buf("/workspace/project").abs(); + + let (sandbox_policy, permissions) = + turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path()); + + assert_eq!(sandbox_policy, None); + assert_eq!(permissions, None); + } + + #[test] + fn legacy_turn_permissions_project_to_sandbox_when_explicitly_overridden() { let cwd = test_path_buf("/workspace/project").abs(); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), - /*active_permission_profile*/ None, + TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()), cwd.as_path(), - std::slice::from_ref(&cwd), - ThreadParamsMode::Embedded, ); assert_eq!( @@ -1755,26 +1760,19 @@ mod tests { } #[test] - fn remote_turn_permissions_use_sandbox_even_with_active_profile() { + fn remote_turn_permissions_preserve_active_profile_selection() { let cwd = test_path_buf("/workspace/project").abs(); + let active_permission_profile = ActivePermissionProfile::new("strict"); + let expected_permissions = + permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - std::slice::from_ref(&cwd), - ThreadParamsMode::Remote, ); - assert_eq!( - sandbox_policy, - Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }) - ); - assert_eq!(permissions, None); + assert_eq!(sandbox_policy, None); + assert_eq!(permissions, Some(expected_permissions)); } #[tokio::test] @@ -2109,7 +2107,6 @@ mod tests { .to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path()) .expect("read-only profile must be legacy-compatible") .into(), - permission_profile: Some(read_only_profile.clone().into()), active_permission_profile: None, reasoning_effort: None, }; @@ -2134,6 +2131,24 @@ mod tests { assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); + let embedded_config = ConfigBuilder::default() + .codex_home(temp_dir.path().join("embedded-codex-home")) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build"); + let started = started_thread_from_resume_response( + response.clone(), + &embedded_config, + ThreadParamsMode::Embedded, + ) + .await + .expect("embedded resume response should map"); + assert_eq!(started.session.permission_profile, read_only_profile); + let mut empty_roots_response = response; empty_roots_response.runtime_workspace_roots = Vec::new(); let started = started_thread_from_resume_response( @@ -2147,61 +2162,43 @@ mod tests { } #[tokio::test] - async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() { + async fn remote_thread_response_uses_legacy_sandbox_fallback() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let cwd = test_path_buf("/tmp/project").abs(); - let fallback_sandbox = PermissionProfile::read_only() + let sandbox = PermissionProfile::read_only() .to_legacy_sandbox_policy(cwd.as_path()) .expect("read-only profile must be legacy-compatible") .into(); - let response_profile = AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { - subpath: Some(".env".into()), - }, - }, - access: FileSystemAccessMode::None, - }, - ], - glob_scan_max_depth: None, - }, - network: PermissionProfileNetworkPermissions { enabled: false }, - }; - let split_profile: PermissionProfile = response_profile.clone().into(); assert_eq!( - permission_profile_from_thread_response( - &fallback_sandbox, - Some(&response_profile), + display_permission_profile_from_thread_response( + &sandbox, cwd.as_path(), &config, ThreadParamsMode::Remote, ), - split_profile + PermissionProfile::read_only() ); } #[tokio::test] - async fn embedded_thread_response_prefers_permission_profile_when_present() { + async fn embedded_thread_response_uses_local_config_profile() { let temp_dir = tempfile::tempdir().expect("tempdir"); - let config = build_config(&temp_dir).await; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build"); let cwd = test_path_buf("/tmp/project").abs(); - let response_profile = PermissionProfile::read_only().into(); assert_eq!( - permission_profile_from_thread_response( + display_permission_profile_from_thread_response( &codex_app_server_protocol::SandboxPolicy::DangerFullAccess, - Some(&response_profile), cwd.as_path(), &config, ThreadParamsMode::Embedded, diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index eee682fd79..c9d964d620 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -34,9 +34,11 @@ pub(crate) struct ThreadSessionState { pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - /// Canonical active permissions for this session. Legacy app-server + /// Permission snapshot used by TUI display surfaces. Legacy app-server /// responses are converted to a profile at ingestion time using the /// response cwd so cached sessions do not reinterpret cwd-bound grants. + /// Turn requests must not treat this snapshot as a local permission + /// override unless the user explicitly changed permissions in the TUI. pub(crate) permission_profile: PermissionProfile, /// Named or implicit built-in profile that produced `permission_profile`, /// when the server knows it. From 8df2d9686096f19d8c514c56ec83f15620585915 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 13:09:25 -0700 Subject: [PATCH 019/114] core: construct test permission profiles directly (#22795) ## Why The core migration is trying to make `PermissionProfile` the shape tests and runtime code reason about, leaving `SandboxPolicy` only where legacy behavior is explicitly under test. The local `permission_profile_for_sandbox_policy()` test helpers kept new permission-profile tests mentally tied to the old sandbox model even when the equivalent profile is straightforward. ## What Changed - Removed the `permission_profile_for_sandbox_policy()` helper from the network proxy spec tests and session tests. - Replaced legacy conversions for read-only, workspace-write, and full-access cases with `PermissionProfile::read_only()`, `PermissionProfile::workspace_write()`, and `PermissionProfile::Disabled`. - Constructed the external-sandbox session test's `PermissionProfile::External` directly, while preserving the legacy `SandboxPolicy` only where the test still exercises legacy config update behavior. ## How To Review This PR is intentionally test-only. Review the two touched files and check that each replacement preserves the old legacy mapping: - `SandboxPolicy::new_read_only_policy()` -> `PermissionProfile::read_only()` - `SandboxPolicy::new_workspace_write_policy()` -> `PermissionProfile::workspace_write()` - `SandboxPolicy::DangerFullAccess` -> `PermissionProfile::Disabled` - `SandboxPolicy::ExternalSandbox { network_access: Restricted }` -> `PermissionProfile::External { network: Restricted }` ## Verification - `cargo test -p codex-core requirements_allowed_domains_are_a_baseline_for_user_allowlist` - `cargo test -p codex-core start_managed_network_proxy_applies_execpolicy_network_rules` - `cargo test -p codex-core session_configured_reports_permission_profile_for_external_sandbox` - `cargo test -p codex-core managed_network_proxy_decider_survives_full_access_start` - `just fix -p codex-core` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22795). * #22891 * __->__ #22795 --- .../src/config/network_proxy_spec_tests.rs | 29 ++++------ codex-rs/core/src/session/tests.rs | 55 +++++++++---------- 2 files changed, 37 insertions(+), 47 deletions(-) diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 14b7c1c330..6dfb1e3a25 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; -fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - fn domain_permissions( entries: impl IntoIterator, ) -> NetworkDomainPermissionsToml { @@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()), + &PermissionProfile::read_only(), ) .expect("config should stay within the managed allowlist"); @@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed allowlist should not erase a user deny"); @@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); @@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("yolo mode should pin the effective policy to the managed baseline"); @@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still load"); @@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only allowlist should still load"); @@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("deny-only requirements should not constrain the allowlist"); @@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("allow-only requirements should not constrain the denylist"); @@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("default mode should merge managed and user deny entries"); @@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9265072dde..aa89efe1d4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -172,10 +172,6 @@ use std::time::Duration as StdDuration; mod guardian_tests; -fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - struct InstructionsTestCase { slug: &'static str, expects_apply_patch_description: bool, @@ -670,10 +666,11 @@ fn validated_network_policy_amendment_host_rejects_mismatch() { #[tokio::test] async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), /*requirements*/ None, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, )?; let mut exec_policy = Policy::empty(); exec_policy.add_network_rule( @@ -686,7 +683,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -705,6 +702,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho #[tokio::test] async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { @@ -717,7 +715,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() managed_allowed_domains_only: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, )?; let mut exec_policy = Policy::empty(); exec_policy.add_network_rule( @@ -730,7 +728,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -748,13 +746,14 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() #[tokio::test] async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> { + let full_access_permission_profile = PermissionProfile::Disabled; let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &full_access_permission_profile, )?; let exec_policy = Policy::empty(); let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -769,7 +768,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &full_access_permission_profile, Some(network_policy_decider), /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ true, @@ -777,9 +776,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R ) .await?; - let spec = spec.recompute_for_permission_profile(&permission_profile_for_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - ))?; + let spec = spec.recompute_for_permission_profile(&PermissionProfile::workspace_write())?; spec.apply_to_started_proxy(&started_proxy).await?; let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!(current_cfg.network.allowed_domains(), None); @@ -818,6 +815,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R #[tokio::test] async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> { let (mut session, _turn_context) = make_session_and_context().await; + let initial_permission_profile = PermissionProfile::workspace_write(); let initial_policy = SandboxPolicy::new_workspace_write_policy(); let mut network_config = NetworkProxyConfig::default(); @@ -836,12 +834,12 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow let spec = crate::config::NetworkProxySpec::from_config_and_constraints( network_config, Some(requirements), - &permission_profile_for_sandbox_policy(&initial_policy), + &initial_permission_profile, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &Policy::empty(), - &permission_profile_for_sandbox_policy(&initial_policy), + &initial_permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -870,9 +868,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow state.session_configuration.original_config_do_not_use = Arc::new(config); state .session_configuration - .set_permission_profile_for_tests(PermissionProfile::from_legacy_sandbox_policy( - &initial_policy, - )) + .set_permission_profile_for_tests(initial_permission_profile) .expect("test setup should allow permission profile"); } session.services.network_proxy = Some(started_proxy); @@ -913,7 +909,7 @@ async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyho enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, )?; let session = make_session_with_config(move |config| { @@ -979,7 +975,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, )?; let session = make_session_with_config(move |config| { @@ -1047,6 +1043,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an #[tokio::test] async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), @@ -1054,7 +1051,7 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&sandbox_policy), + &permission_profile, )?; let session = make_session_with_config(move |config| { @@ -1074,6 +1071,7 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any #[tokio::test] async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), @@ -1081,7 +1079,7 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&sandbox_policy), + &permission_profile, )?; let (session, rx) = make_session_with_config_and_rx(move |config| { @@ -2127,13 +2125,14 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> let sandbox_policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let expected_sandbox_policy = sandbox_policy.clone(); + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }; + let expected_permission_profile = permission_profile.clone(); let mut builder = test_codex().with_config(move |config| { config .permissions - .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( - &sandbox_policy, - )) + .set_permission_profile(permission_profile.clone()) .expect("set permission profile"); config .set_legacy_sandbox_policy(sandbox_policy) @@ -2142,10 +2141,6 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> let test = builder.build(&server).await?; - let expected_permission_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &expected_sandbox_policy, - ); assert_eq!( test.session_configured.permission_profile, expected_permission_profile, "ExternalSandbox is represented explicitly instead of as a lossy root-write profile" From 98129fb9c5a1c60118523534aba6f3de89fd0585 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 15 May 2026 14:19:06 -0700 Subject: [PATCH 020/114] Disable DMG staging for signed macOS promotion (#22900) ## Why `promote_signed` is now used to finish a release from an externally signed macOS handoff, but this release path (temporarily) no longer distributes DMGs. Keeping DMG staging enabled made the handoff unnecessarily require DMG assets and notarization/stapling validation even though the promoted release only needs the signed macOS binaries. ## What changed - Set every `stage-signed-macos` matrix entry to `build_dmg: "false"`, including the primary macOS bundles. - Kept the existing DMG staging branch in place behind `matrix.build_dmg` so it can be re-enabled deliberately later. - Updated the workflow header comment so the signed handoff contract asks for signed binaries, not signed DMGs. The regular signed build path that creates, signs, notarizes, and stages DMGs is unchanged; this only affects the `promote_signed` handoff path. --- .github/workflows/rust-release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 0e13aa366c..b6c293d6cd 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,7 +10,7 @@ # archive as a GitHub Release asset, then manually dispatch # `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. # The signed handoff archive should contain target or artifact directories such -# as `aarch64-apple-darwin/` with signed binaries and signed DMGs. +# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: @@ -644,7 +644,7 @@ jobs: bundle: primary artifact_name: aarch64-apple-darwin binaries: "codex codex-responses-api-proxy" - build_dmg: "true" + build_dmg: "false" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server @@ -654,7 +654,7 @@ jobs: bundle: primary artifact_name: x86_64-apple-darwin binaries: "codex codex-responses-api-proxy" - build_dmg: "true" + build_dmg: "false" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server @@ -770,6 +770,9 @@ jobs: codesign --verify --strict --verbose=2 "$release_path" done + # DMG staging is disabled for signed promotion because we no longer + # distribute DMGs from this release path. Keep the branch here so the + # handoff can opt back in by flipping matrix.build_dmg if needed. if [[ "${{ matrix.build_dmg }}" == "true" ]]; then dmg_name="codex-${target}.dmg" dmg_source="${source_dir}/${dmg_name}" From 6a331a66eb1d526f9b647b07fadf0c2e2833a3d2 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 15 May 2026 14:33:24 -0700 Subject: [PATCH 021/114] feat(app-server): update remote control APIs for better UX (#22877) ## Why To help improve `codex remote-control` CLI UX which I plan to do in a followup, this PR adds `server-name` to the various remote control APIs: - `remoteControl/enable` - `remoteControl/disable` - `remoteControl/status/changed` Also, add a `remoteControl/status/read` API. This will be helpful in the Codex App. --- .../schema/json/ServerNotification.json | 4 + .../codex_app_server_protocol.schemas.json | 4 + .../codex_app_server_protocol.v2.schemas.json | 4 + ...emoteControlStatusChangedNotification.json | 4 + .../RemoteControlStatusChangedNotification.ts | 2 +- .../src/protocol/common.rs | 6 + .../src/protocol/v2/remote_control.rs | 17 ++ .../src/transport/remote_control/enroll.rs | 8 +- .../src/transport/remote_control/mod.rs | 31 ++-- .../src/transport/remote_control/tests.rs | 11 ++ .../src/transport/remote_control/websocket.rs | 38 +++-- codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 4 + .../remote_control_processor.rs | 11 ++ .../app-server/tests/common/mcp_process.rs | 6 + .../tests/suite/v2/remote_control.rs | 146 ++++++++++++++++++ 17 files changed, 276 insertions(+), 33 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6af19f89e2..a5fce14a4f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2748,12 +2748,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "type": "object" 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 935ec98e88..d1b318889b 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 @@ -13446,12 +13446,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", 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 ccf68b28d8..bb926075ee 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 @@ -9995,12 +9995,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json index 85be3316d7..2f305c7d78 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -22,12 +22,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 8c63ab9029..403b0e6457 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -6,4 +6,4 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionSta /** * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, serverName: string, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f7e04b3dc5..9681f70c34 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -812,6 +812,12 @@ client_request_definitions! { serialization: global("remote-control"), response: v2::RemoteControlDisableResponse, }, + #[experimental("remoteControl/status/read")] + RemoteControlStatusRead => "remoteControl/status/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("remote-control"), + response: v2::RemoteControlStatusReadResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index e89a9d19b8..c8ad617e7d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -9,6 +9,7 @@ use ts_rs::TS; #[ts(export_to = "v2/")] pub struct RemoteControlStatusChangedNotification { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -18,6 +19,7 @@ pub struct RemoteControlStatusChangedNotification { #[ts(export_to = "v2/")] pub struct RemoteControlEnableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -27,6 +29,17 @@ pub struct RemoteControlEnableResponse { #[ts(export_to = "v2/")] pub struct RemoteControlDisableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusReadResponse { + pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -45,11 +58,13 @@ impl From for RemoteControlEnableRespons fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } @@ -60,11 +75,13 @@ impl From for RemoteControlDisableRespon fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 60dfc3d845..9ec6e4d004 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -6,7 +6,6 @@ use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; -use gethostname::gethostname; use std::io; use std::io::ErrorKind; use tracing::info; @@ -195,11 +194,11 @@ pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, + server_name: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; - let server_name = gethostname().to_string_lossy().trim().to_string(); let request = EnrollRemoteServerRequest { - name: server_name.clone(), + name: server_name.to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), @@ -255,7 +254,7 @@ pub(super) async fn enroll_remote_control_server( account_id: auth.account_id.clone(), environment_id: enrollment.environment_id, server_id: enrollment.server_id, - server_name, + server_name: server_name.to_string(), }) } @@ -464,6 +463,7 @@ mod tests { account_id: "account_id".to_string(), }, "11111111-1111-4111-8111-111111111111", + "test-server", ) .await .expect_err("invalid response should fail to parse"); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 8722f4e9ee..ae6d79462c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; @@ -112,15 +113,8 @@ impl RemoteControlHandle { connection_status: RemoteControlConnectionStatus, ) -> RemoteControlStatusChangedNotification { self.status_tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -132,6 +126,22 @@ impl RemoteControlHandle { } } +fn remote_control_status_with_connection_status( + status: &RemoteControlStatusChangedNotification, + connection_status: RemoteControlConnectionStatus, +) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status: connection_status, + server_name: status.server_name.clone(), + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + } +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -154,12 +164,14 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let server_name = gethostname().to_string_lossy().trim().to_string(); let initial_status = RemoteControlStatusChangedNotification { status: if initial_enabled { RemoteControlConnectionStatus::Connecting } else { RemoteControlConnectionStatus::Disabled }, + server_name: server_name.clone(), installation_id: config.installation_id.clone(), environment_id: None, }; @@ -171,6 +183,7 @@ pub async fn start_remote_control( remote_control_url: config.remote_control_url, installation_id: config.installation_id, remote_control_target, + server_name, }, state_db, auth_manager, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 3dcfb81ce5..fb1512fedb 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -121,6 +121,10 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String { format!("http://{addr}/backend-api/") } +fn test_server_name() -> String { + gethostname().to_string_lossy().trim().to_string() +} + async fn expect_remote_control_status( status_rx: &mut watch::Receiver, expected_status: Option, @@ -134,6 +138,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.server_name, test_server_name()); assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -630,6 +635,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -698,6 +704,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, @@ -708,6 +715,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.disable(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -716,6 +724,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, @@ -732,6 +741,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.enable().expect("enable should succeed"), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -740,6 +750,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 472639bc68..f117aec3b4 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -15,6 +15,7 @@ use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; +use super::remote_control_status_with_connection_status; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -216,6 +217,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, installation_id: String, + server_name: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -235,6 +237,7 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) remote_control_url: String, pub(crate) installation_id: String, pub(crate) remote_control_target: Option, + pub(crate) server_name: String, } enum ConnectOutcome { @@ -260,15 +263,8 @@ impl RemoteControlStatusPublisher { fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { self.tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -285,6 +281,7 @@ impl RemoteControlStatusPublisher { } let next_status = RemoteControlStatusChangedNotification { status: status.status, + server_name: status.server_name.clone(), installation_id: status.installation_id.clone(), environment_id, }; @@ -301,6 +298,7 @@ impl RemoteControlStatusPublisher { #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { installation_id: &'a str, + server_name: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } @@ -327,6 +325,7 @@ impl RemoteControlWebsocket { Self { remote_control_url: config.remote_control_url, installation_id: config.installation_id, + server_name: config.server_name, remote_control_target: config.remote_control_target, state_db, auth_manager, @@ -454,6 +453,7 @@ impl RemoteControlWebsocket { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); let connect_options = RemoteControlConnectOptions { installation_id: &self.installation_id, + server_name: &self.server_name, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; @@ -1076,7 +1076,10 @@ pub(super) async fn connect_remote_control_websocket( if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone())); } - *enrollment = loaded_enrollment; + *enrollment = loaded_enrollment.map(|mut enrollment| { + enrollment.server_name = connect_options.server_name.to_string(); + enrollment + }); } if enrollment.is_none() { @@ -1088,6 +1091,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, &auth, connect_options.installation_id, + connect_options.server_name, ) .await { @@ -1279,6 +1283,7 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); @@ -1386,6 +1391,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1403,6 +1409,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1464,6 +1471,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1477,6 +1485,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1546,6 +1555,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1599,6 +1609,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1647,6 +1658,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1665,6 +1677,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1694,6 +1707,7 @@ mod tests { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), remote_control_target: Some(remote_control_target), + server_name: "test-server".to_string(), }, /*state_db*/ None, remote_control_auth_manager(), @@ -1738,6 +1752,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1759,6 +1774,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1773,6 +1789,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1788,6 +1805,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4d21eb2846..8e3a23f0f7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -204,7 +204,8 @@ Example with notification opt-out: - `app/list` — list available apps. - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. -- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. +- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 673dec6341..4482d326f6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -41,7 +41,6 @@ use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; @@ -1003,14 +1002,9 @@ pub async fn run_main_with_transport_options( continue; } remote_control_status = status.clone(); + let notification = ServerNotification::RemoteControlStatusChanged(status); initialize_notification_sender - .send_server_notification(ServerNotification::RemoteControlStatusChanged( - RemoteControlStatusChangedNotification { - status: status.status, - installation_id: status.installation_id, - environment_id: status.environment_id, - }, - )) + .send_server_notification(notification) .await; } created = thread_created_rx.recv(), if listen_for_threads => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f291abfcc..79c077dec7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -897,6 +897,10 @@ impl MessageProcessor { .remote_control_processor .disable() .map(|response| Some(response.into())), + ClientRequest::RemoteControlStatusRead { .. } => self + .remote_control_processor + .status_read() + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 41a124e94b..2fceeb5030 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -5,6 +5,7 @@ use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -31,6 +32,16 @@ impl RemoteControlRequestProcessor { Ok(RemoteControlDisableResponse::from(handle.disable())) } + pub(crate) fn status_read(&self) -> Result { + let status = self.handle()?.status(); + Ok(RemoteControlStatusReadResponse { + status: status.status, + server_name: status.server_name, + installation_id: status.installation_id, + environment_id: status.environment_id, + }) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 19f3b5b85e..981c22f625 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -582,6 +582,12 @@ impl McpProcess { .await } + /// Send a `remoteControl/status/read` JSON-RPC request. + pub async fn send_remote_control_status_read_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/status/read", /*params*/ None) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index ae6bc9013f..d71f05bf58 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -1,14 +1,27 @@ use std::time::Duration; +use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -28,6 +41,28 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { let received: RemoteControlDisableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_status_read_returns_disabled_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) @@ -36,6 +71,7 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; + let _backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -48,7 +84,117 @@ async fn remote_control_enable_returns_connecting_status() -> Result<()> { let received: RemoteControlEnableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) } + +#[tokio::test] +async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let enroll_request = timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??; + assert_eq!( + enroll_request, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +struct BlockingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl BlockingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + match read_enroll_request(listener).await { + Ok((request_line, _reader)) => { + let _ = enroll_request_tx.send(Ok(request_line)); + std::future::pending::<()>().await; + } + Err(err) => { + let _ = enroll_request_tx.send(Err(err)); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + let rx = self + .enroll_request_rx + .take() + .context("enroll request should only be awaited once")?; + rx.await? + } +} + +impl Drop for BlockingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + +async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + } + + Ok((request_line.trim_end().to_string(), reader)) +} From 249d50aafc552d2fdcd3836a50b03ce366d9744f Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 15 May 2026 14:37:27 -0700 Subject: [PATCH 022/114] [codex] Soften SQLite metadata sync failures (#22899) ## Summary - keep transcript-derived local thread metadata SQLite failures best-effort - preserve hard failures for explicit git-only metadata updates that still require SQLite state - add regression coverage for the soft-vs-hard metadata update policy ## Root cause The live thread metadata sync introduced after v0.131.0-alpha.8 moved append-derived metadata writes above the rollout writer. Those SQLite writes now propagated through the live thread flush path, so a corrupted optional state DB could surface as a transcript persistence warning even when JSONL writes still succeeded. The hard failures were introduced in #22236 --- .../src/local/update_thread_metadata.rs | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index c09ca80f97..16f9f79e06 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -14,6 +14,7 @@ use codex_rollout::find_archived_thread_path_by_id_str; use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; use codex_state::ThreadMetadataBuilder; +use tracing::warn; use super::LocalThreadStore; use super::helpers::git_info_from_parts; @@ -51,8 +52,15 @@ pub(super) async fn update_thread_metadata( } let needs_rollout_compat = needs_rollout_compatibility_update(&patch); - let updated = - apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?; + let require_sqlite_write = sqlite_write_failure_should_block(&patch); + let updated = apply_metadata_update( + store, + thread_id, + patch.clone(), + params.include_archived, + require_sqlite_write, + ) + .await?; if !needs_rollout_compat { return Ok(updated); } @@ -169,6 +177,7 @@ async fn apply_metadata_update( thread_id: ThreadId, patch: ThreadMetadataPatch, include_archived: bool, + require_sqlite_write: bool, ) -> ThreadStoreResult { let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok(); let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path); @@ -316,9 +325,19 @@ async fn apply_metadata_update( }; match (state_db.is_some(), sqlite_write_result) { (true, Ok(())) => {} - (true, Err(err)) => return Err(err), + (true, Err(err)) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => { + return Err(err); + } + (true, Err(err)) => { + warn!("state db update_thread_metadata failed for {thread_id}: {err}"); + } (false, Ok(())) => {} - (false, Err(err)) => return Err(err), + (false, Err(err)) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => { + return Err(err); + } + (false, Err(err)) => { + warn!("state db update_thread_metadata failed for {thread_id}: {err}"); + } } read_thread::read_thread( @@ -342,6 +361,19 @@ fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool { !has_observed_metadata_facts(patch) } +fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool { + // Before live metadata sync moved above the rollout writer, SQLite sync failures for + // transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that + // failure isolation so a corrupted optional state DB does not make JSONL transcript durability + // look broken. Explicit git-only updates still require SQLite because partial git patches need + // the existing SQLite value to preserve unspecified fields. + patch.git_info.is_some() && !has_observed_metadata_facts(patch) +} + +fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool { + matches!(err, ThreadStoreError::Internal { .. }) +} + fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { patch.rollout_path.is_some() || patch.preview.is_some() @@ -1136,6 +1168,46 @@ mod tests { assert_eq!(memory_mode.as_deref(), Some("disabled")); } + #[test] + fn sqlite_failures_are_best_effort_for_legacy_rollout_compat_updates() { + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + name: Some(Some("User chosen name".to_string())), + ..Default::default() + })); + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + memory_mode: Some(ThreadMemoryMode::Disabled), + ..Default::default() + })); + } + + #[test] + fn sqlite_failures_are_best_effort_for_observed_metadata_updates() { + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + updated_at: Some(Utc::now()), + ..Default::default() + })); + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + preview: Some("Observed preview".to_string()), + git_info: Some(GitInfoPatch { + branch: Some(Some("main".to_string())), + ..Default::default() + }), + memory_mode: Some(ThreadMemoryMode::Enabled), + ..Default::default() + })); + } + + #[test] + fn sqlite_failures_still_block_for_explicit_git_only_updates() { + assert!(sqlite_write_failure_should_block(&ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + branch: Some(Some("main".to_string())), + ..Default::default() + }), + ..Default::default() + })); + } + #[tokio::test] async fn metadata_patch_applies_title_over_existing_name() { let home = TempDir::new().expect("temp dir"); From 8dfeb972c7aa703039577a69fe03ee33827a95b8 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 22:00:00 +0000 Subject: [PATCH 023/114] Address subagent start hook review feedback --- codex-rs/core/src/hook_runtime.rs | 34 +-- codex-rs/core/src/session/tests.rs | 6 +- .../tests/suite/subagent_notifications.rs | 16 +- codex-rs/hooks/src/events/session_start.rs | 204 ++++++++++-------- codex-rs/hooks/src/lib.rs | 2 +- 5 files changed, 143 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 8647c3ef5b..97c724d3c1 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -13,7 +13,7 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; -use codex_hooks::SessionStartTarget; +use codex_hooks::StartHookTarget; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -117,6 +117,9 @@ pub(crate) async fn run_pending_session_start_hooks( return false; }; + // Pending session-start hooks are reused to dispatch thread-spawn subagent + // starts. Other subagent sessions are internal/system work and do not run + // start hooks. let target = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) if matches!( @@ -124,15 +127,17 @@ pub(crate) async fn run_pending_session_start_hooks( codex_hooks::SessionStartSource::Startup ) => { - let metadata = subagent_hook_metadata(sess, agent_role); - SessionStartTarget::SubagentStart { + let agent_type = agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + StartHookTarget::SubagentStart { turn_id: turn_context.sub_id.clone(), - agent_id: metadata.agent_id, - agent_type: metadata.agent_type, + agent_id: sess.thread_id().to_string(), + agent_type, } } SessionSource::SubAgent(_) => return false, - _ => SessionStartTarget::SessionStart { + _ => StartHookTarget::SessionStart { source: session_start_source, }, }; @@ -618,23 +623,6 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } -struct SubagentHookMetadata { - agent_id: String, - agent_type: String, -} - -fn subagent_hook_metadata( - sess: &Arc, - agent_role: &Option, -) -> SubagentHookMetadata { - SubagentHookMetadata { - agent_id: sess.thread_id().to_string(), - agent_type: agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()), - } -} - fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { match value { CompactionTrigger::Manual => "manual", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4f441ea08f..eaeaf77295 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -532,7 +532,7 @@ async fn preview_session_start_hooks( transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }), @@ -1306,7 +1306,7 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }; @@ -1413,7 +1413,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - target: codex_hooks::SessionStartTarget::SessionStart { + target: codex_hooks::StartHookTarget::SessionStart { source: codex_hooks::SessionStartSource::Startup, }, }; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c74269e585..a289cc8f7e 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -439,8 +439,12 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let child_requests = wait_for_requests(&child_request_log).await?; assert_eq!(child_requests.len(), 1); - let start_inputs = - wait_for_hook_log(test.codex_home_path(), "subagent_start_hook_log.jsonl", 1).await?; + let start_inputs = wait_for_hook_log( + test.codex_home_path(), + "subagent_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; assert_eq!(start_inputs.len(), 1); assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); let spawned_id = wait_for_spawned_thread_id(&test).await?; @@ -449,8 +453,12 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( Some(spawned_id.as_str()) ); - let session_start_inputs = - wait_for_hook_log(test.codex_home_path(), "session_start_hook_log.jsonl", 1).await?; + let session_start_inputs = wait_for_hook_log( + test.codex_home_path(), + "session_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; assert_eq!(session_start_inputs.len(), 1); assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); assert_ne!( diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index fdf550b690..76eb508083 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -43,11 +43,11 @@ pub struct SessionStartRequest { pub transcript_path: Option, pub model: String, pub permission_mode: String, - pub target: SessionStartTarget, + pub target: StartHookTarget, } #[derive(Debug, Clone)] -pub enum SessionStartTarget { +pub enum StartHookTarget { SessionStart { source: SessionStartSource, }, @@ -58,7 +58,7 @@ pub enum SessionStartTarget { }, } -impl SessionStartTarget { +impl StartHookTarget { fn event_name(&self) -> HookEventName { match self { Self::SessionStart { .. } => HookEventName::SessionStart, @@ -123,9 +123,9 @@ pub(crate) async fn run( }; } - let (input_json, turn_id, parse_completed) = match request.target { - SessionStartTarget::SessionStart { source } => { - match serde_json::to_string(&SessionStartCommandInput::new( + let (input_json, turn_id) = match request.target { + StartHookTarget::SessionStart { source } => { + let input_json = match serde_json::to_string(&SessionStartCommandInput::new( request.session_id.to_string(), request.transcript_path.clone(), request.cwd.display().to_string(), @@ -133,17 +133,7 @@ pub(crate) async fn run( request.permission_mode.clone(), source.as_str().to_string(), )) { - Ok(input_json) => ( - input_json, - turn_id, - parse_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -153,16 +143,17 @@ pub(crate) async fn run( ), ); } - } + }; + (input_json, turn_id) } - SessionStartTarget::SubagentStart { - turn_id, + StartHookTarget::SubagentStart { + turn_id: subagent_turn_id, agent_id, agent_type, } => { let input = SubagentStartCommandInput { session_id: request.session_id.to_string(), - turn_id: turn_id.clone(), + turn_id: subagent_turn_id.clone(), transcript_path: NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "SubagentStart".to_string(), @@ -171,28 +162,19 @@ pub(crate) async fn run( agent_id, agent_type, }; - match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - Some(turn_id), - parse_subagent_start_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + let input_json = match serde_json::to_string(&input) { + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( matched, - Some(turn_id), + Some(subagent_turn_id), format!("failed to serialize subagent start hook input: {error}"), ), ); } - } + }; + (input_json, Some(subagent_turn_id)) } }; @@ -224,55 +206,15 @@ pub(crate) async fn run( } } +/// Interprets completed `SessionStart` and `SubagentStart` hook runs. +/// +/// The two events have different input payloads but share output handling: +/// hook JSON can emit warnings/context or stop the session being started, +/// invalid JSON-looking stdout fails, and plain stdout becomes model context. fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_start_completed( - handler, - run_result, - turn_id, - "session start", - output_parser::parse_session_start, - StartHookSemantics { - allow_plain_text_context: true, - allow_stop: true, - }, - ) -} - -fn parse_subagent_start_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_start_completed( - handler, - run_result, - turn_id, - "subagent start", - output_parser::parse_subagent_start, - StartHookSemantics { - allow_plain_text_context: false, - allow_stop: false, - }, - ) -} - -#[derive(Clone, Copy)] -struct StartHookSemantics { - allow_plain_text_context: bool, - allow_stop: bool, -} - -fn parse_start_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, - hook_label: &str, - parse_output: fn(&str) -> Option, - semantics: StartHookSemantics, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -292,7 +234,17 @@ fn parse_start_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = parse_output(&run_result.stdout) { + } else if let Some(parsed) = match handler.event_name { + HookEventName::SessionStart => { + output_parser::parse_session_start(&run_result.stdout) + } + HookEventName::SubagentStart => { + output_parser::parse_subagent_start(&run_result.stdout) + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -307,7 +259,7 @@ fn parse_start_completed( ); } let _ = parsed.universal.suppress_output; - if semantics.allow_stop && !parsed.universal.continue_processing { + if !parsed.universal.continue_processing { status = HookRunStatus::Stopped; should_stop = true; stop_reason = parsed.universal.stop_reason.clone(); @@ -318,14 +270,22 @@ fn parse_start_completed( }); } } - // Preserve plain-text context support without treating malformed JSON as context. - } else if !semantics.allow_plain_text_context - || output_parser::looks_like_json(&run_result.stdout) - { + } else if output_parser::looks_like_json(&run_result.stdout) { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!("hook returned invalid {hook_label} JSON output"), + text: match handler.event_name { + HookEventName::SessionStart => { + "hook returned invalid session start JSON output" + } + HookEventName::SubagentStart => { + "hook returned invalid subagent start JSON output" + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } + .to_string(), }); } else { let additional_context = trimmed_stdout.to_string(); @@ -485,9 +445,77 @@ mod tests { ); } + #[test] + fn subagent_start_plain_stdout_becomes_model_context() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result(Some(0), "hello from subagent hook\n", ""), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: false, + stop_reason: None, + additional_contexts_for_model: vec!["hello from subagent hook".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "hello from subagent hook".to_string(), + }] + ); + } + + #[test] + fn subagent_start_continue_false_stops_subagent() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result( + Some(0), + r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#, + "", + ), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: true, + stop_reason: Some("skip child".to_string()), + additional_contexts_for_model: vec!["child context".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); + assert_eq!( + parsed.completed.run.entries, + vec![ + HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "child context".to_string(), + }, + HookOutputEntry { + kind: HookOutputEntryKind::Stop, + text: "skip child".to_string(), + }, + ] + ); + } + fn handler() -> ConfiguredHandler { + handler_for(HookEventName::SessionStart) + } + + fn handler_for(event_name: HookEventName) -> ConfiguredHandler { ConfiguredHandler { - event_name: HookEventName::SessionStart, + event_name, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 93bbe72ba5..5675d11e71 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -56,7 +56,7 @@ pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; -pub use events::session_start::SessionStartTarget; +pub use events::session_start::StartHookTarget; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; From 8543e39885956b5d38b416acd1868b5f643cc0ed Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 15 May 2026 15:04:04 -0700 Subject: [PATCH 024/114] Preserve image detail in app-server inputs (#20693) ## Summary - Add optional image detail to user image inputs across core, app-server v2, thread history/event mapping, and the generated app-server schemas/types. - Preserve requested detail when serializing Responses image inputs: omitted detail stays on the existing `high` default, while explicit `original` keeps local images on the original-resolution path. - Support `high`/`original` consistently for tool image outputs, including MCP `codex/imageDetail`, code-mode image helpers, and `view_image`. --- .../analytics/src/analytics_client_tests.rs | 2 + .../schema/json/ClientRequest.json | 24 ++- .../schema/json/ServerNotification.json | 29 +++ .../codex_app_server_protocol.schemas.json | 24 ++- .../codex_app_server_protocol.v2.schemas.json | 24 ++- .../json/v2/ItemCompletedNotification.json | 29 +++ .../json/v2/ItemStartedNotification.json | 29 +++ .../RawResponseItemCompletedNotification.json | 2 - .../schema/json/v2/ReviewStartResponse.json | 29 +++ .../schema/json/v2/ThreadForkResponse.json | 29 +++ .../schema/json/v2/ThreadListResponse.json | 29 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 29 +++ .../schema/json/v2/ThreadReadResponse.json | 29 +++ .../schema/json/v2/ThreadResumeParams.json | 2 - .../schema/json/v2/ThreadResumeResponse.json | 29 +++ .../json/v2/ThreadRollbackResponse.json | 29 +++ .../schema/json/v2/ThreadStartResponse.json | 29 +++ .../json/v2/ThreadStartedNotification.json | 29 +++ .../json/v2/ThreadUnarchiveResponse.json | 29 +++ .../json/v2/TurnCompletedNotification.json | 29 +++ .../schema/json/v2/TurnStartParams.json | 29 +++ .../schema/json/v2/TurnStartResponse.json | 29 +++ .../json/v2/TurnStartedNotification.json | 29 +++ .../schema/json/v2/TurnSteerParams.json | 29 +++ .../schema/typescript/ImageDetail.ts | 2 +- .../schema/typescript/v2/UserInput.ts | 3 +- .../src/protocol/thread_history.rs | 91 ++++++++- .../src/protocol/v2/tests.rs | 32 ++++ .../src/protocol/v2/turn.rs | 21 +- .../app-server/src/bespoke_event_handling.rs | 2 + .../thread_processor_tests.rs | 1 + .../request_processors/token_usage_replay.rs | 2 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 180 ++++++++++++------ codex-rs/cli/src/main.rs | 2 +- codex-rs/code-mode/src/description.rs | 2 +- codex-rs/code-mode/src/response.rs | 2 - codex-rs/code-mode/src/runtime/value.rs | 8 +- codex-rs/code-mode/src/service.rs | 36 +++- codex-rs/core/src/agent/control.rs | 4 +- codex-rs/core/src/event_mapping.rs | 3 +- codex-rs/core/src/event_mapping_tests.rs | 20 +- .../core/src/personality_migration_tests.rs | 1 + .../session/rollout_reconstruction_tests.rs | 23 +++ codex-rs/core/src/session/tests.rs | 10 + codex-rs/core/src/thread_manager_tests.rs | 2 + .../src/tools/code_mode/response_adapter.rs | 2 - .../core/src/tools/handlers/view_image.rs | 84 +++++++- .../src/tools/handlers/view_image_spec.rs | 14 +- codex-rs/core/src/tools/spec_plan_tests.rs | 17 +- codex-rs/core/tests/suite/code_mode.rs | 2 +- codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/compact_remote.rs | 5 +- .../core/tests/suite/compact_remote_parity.rs | 1 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/model_switching.rs | 1 + .../core/tests/suite/personality_migration.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 4 +- codex-rs/exec/src/lib.rs | 4 +- .../external-agent-sessions/src/export.rs | 1 + .../tests/suite/otel_export_routing_policy.rs | 2 + codex-rs/protocol/src/items.rs | 40 +++- codex-rs/protocol/src/models.rs | 100 ++++++++-- codex-rs/protocol/src/protocol.rs | 68 ++++++- codex-rs/protocol/src/user_input.rs | 16 +- codex-rs/rollout/src/recorder_tests.rs | 2 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/state/src/extract.rs | 4 + codex-rs/thread-store/src/local/mod.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + codex-rs/tools/src/image_detail.rs | 2 +- codex-rs/tools/src/image_detail_tests.rs | 8 +- codex-rs/tui/src/app/tests.rs | 2 +- .../tui/src/chatwidget/input_submission.rs | 2 + .../chatwidget/tests/composer_submission.rs | 9 +- .../src/chatwidget/tests/history_replay.rs | 4 + .../tui/src/chatwidget/tests/review_mode.rs | 1 + codex-rs/tui/src/chatwidget/user_messages.rs | 4 +- codex-rs/tui/src/ide_context/prompt.rs | 2 + 81 files changed, 1302 insertions(+), 156 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ed626699d3..f3c56bfb3d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -279,6 +279,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest }, UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }, ], ..Default::default() @@ -399,6 +400,7 @@ fn sample_turn_steer_request( }, UserInput::LocalImage { path: "/tmp/a.png".into(), + detail: None, }, ], responsesapi_client_metadata: None, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index fffc610128..5fc44d4730 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1235,8 +1235,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -4250,6 +4248,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -4270,6 +4279,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a5fce14a4f..6e8b5d8a04 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1932,6 +1932,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "ItemCompletedNotification": { "properties": { "completedAtMs": { @@ -4593,6 +4600,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -4613,6 +4631,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 d1b318889b..5813afcdb2 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 @@ -9920,8 +9920,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -18134,6 +18132,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -18154,6 +18163,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 bb926075ee..d14acc7a1b 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 @@ -6420,8 +6420,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -15958,6 +15956,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -15978,6 +15987,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 6909415c2a..7aed9d9722 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -285,6 +285,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1179,6 +1186,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1217,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 758ceba32d..80a3cd2425 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -285,6 +285,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1179,6 +1186,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1217,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index bd88872fb6..74420ea57e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -145,8 +145,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], 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 9afd1ae514..882fa1f598 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 1608b2f48c..3d66152a60 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -527,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -2012,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2032,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 f78fbaf27e..0693b19028 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 4268ad203a..d2e4ca6728 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 fb0d80a047..431ca759de 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index e8a437f003..253ebf4572 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -204,8 +204,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], 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 302c2e1069..f379455b59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -527,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -2012,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2032,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 204828c732..8d807d76cb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 9dc08614cf..7d31c52f1c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -527,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -2012,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2032,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 759b5990be..3ec843e817 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 f64400129a..ea48e34180 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 e5e2558e9c..40b81e5a34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 086d3c6ed1..ecea8f1997 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -99,6 +99,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -351,6 +358,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -371,6 +389,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 a2eff7fdd8..ffe0a28d15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 0952db2aca..cbde541e52 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index a064d9e7e3..1b7cfbf400 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -20,6 +20,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -75,6 +82,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -95,6 +113,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts index a48f07c088..5a62cc32f1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ImageDetail = "auto" | "low" | "high" | "original"; +export type ImageDetail = "high" | "original"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts index 38abc2338b..2ac37c5228 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts @@ -1,10 +1,11 @@ // 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. +import type { ImageDetail } from "../ImageDetail"; import type { TextElement } from "./TextElement"; export type UserInput = { "type": "text", text: string, /** * UI-defined spans within `text` used to render or persist special elements. */ -text_elements: Array, } | { "type": "image", url: string, } | { "type": "localImage", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; +text_elements: Array, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; 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 1121d3a35b..3918e4a9bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1077,12 +1077,18 @@ impl ThreadHistoryBuilder { }); } if let Some(images) = &payload.images { - for image in images { - content.push(UserInput::Image { url: image.clone() }); + for (idx, image) in images.iter().enumerate() { + content.push(UserInput::Image { + url: image.clone(), + detail: payload.image_details.get(idx).copied().flatten(), + }); } } - for path in &payload.local_images { - content.push(UserInput::LocalImage { path: path.clone() }); + for (idx, path) in payload.local_images.iter().enumerate() { + content.push(UserInput::LocalImage { + path: path.clone(), + detail: payload.local_image_details.get(idx).copied().flatten(), + }); } content } @@ -1203,6 +1209,7 @@ mod tests { use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -1241,6 +1248,7 @@ mod tests { images: Some(vec!["https://example.com/one.png".into()]), text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Hi there".into(), @@ -1258,6 +1266,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Reply two".into(), @@ -1288,6 +1297,7 @@ mod tests { }, UserInput::Image { url: "https://example.com/one.png".into(), + detail: None, } ], } @@ -1335,6 +1345,45 @@ mod tests { ); } + #[test] + fn rebuilds_user_message_image_details_from_legacy_events() { + let local_path = PathBuf::from("/tmp/local.png"); + let events = vec![RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "inspect these".into(), + images: Some(vec!["https://example.com/image.png".into()]), + image_details: vec![Some(ImageDetail::Original)], + local_images: vec![local_path.clone()], + local_image_details: vec![Some(ImageDetail::Original)], + text_elements: Vec::new(), + }, + ))]; + + let turns = build_turns_from_rollout_items(&events); + + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![ + UserInput::Text { + text: "inspect these".into(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".into(), + detail: Some(ImageDetail::Original), + }, + UserInput::LocalImage { + path: local_path, + detail: Some(ImageDetail::Original), + }, + ], + } + ); + } + #[test] fn ignores_non_plan_item_lifecycle_events() { let turn_id = "turn-1"; @@ -1351,6 +1400,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ItemStarted(ItemStartedEvent { thread_id, @@ -1428,6 +1478,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { call_id: "ig_123".into(), @@ -1485,6 +1536,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "first summary".into(), @@ -1537,6 +1589,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Working...".into(), @@ -1554,6 +1607,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Second attempt complete.".into(), @@ -1624,6 +1678,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1635,6 +1690,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1647,6 +1703,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A3".into(), @@ -1712,6 +1769,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1723,6 +1781,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1754,12 +1813,14 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::UserMessage(UserMessageEvent { message: "Steer".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -1812,6 +1873,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: "search-1".into(), @@ -1984,6 +2046,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::DynamicToolCallRequest( codex_protocol::dynamic_tools::DynamicToolCallRequest { @@ -2049,6 +2112,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-declined".into(), @@ -2138,6 +2202,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-exec".into(), @@ -2221,6 +2286,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-execve".into(), @@ -2284,6 +2350,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2303,6 +2370,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-late".into(), @@ -2376,6 +2444,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2395,6 +2464,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-unknown-turn".into(), @@ -2463,6 +2533,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id: "patch-call".into(), @@ -2527,6 +2598,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), @@ -2591,6 +2663,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2610,6 +2683,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2657,6 +2731,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2676,6 +2751,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), @@ -2748,6 +2824,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), @@ -2805,6 +2882,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { call_id: "spawn-1".into(), @@ -2866,6 +2944,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentInteractionBegin( codex_protocol::protocol::CollabAgentInteractionBeginEvent { @@ -2929,6 +3008,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "done".into(), @@ -2965,6 +3045,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -3020,6 +3101,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::Error(ErrorEvent { message: "stream failure".into(), @@ -3077,6 +3159,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::ResponseItem(hook_prompt), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d17a3dd988..526cc22131 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -15,6 +15,7 @@ use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ImageDetail; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; @@ -2319,9 +2320,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, CoreUserInput::Image { image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, CoreUserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, CoreUserInput::Skill { name: "skill-creator".to_string(), @@ -2345,9 +2348,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, UserInput::Image { url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, UserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, UserInput::Skill { name: "skill-creator".to_string(), @@ -2562,6 +2567,33 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); } +#[test] +fn user_input_into_core_preserves_image_detail() { + assert_eq!( + UserInput::Image { + url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + ); + + assert_eq!( + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + ); +} + #[test] fn skills_list_params_serialization_uses_force_reload() { assert_eq!( 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 8f4cd04e2d..8781dd2bd5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -7,6 +7,7 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; @@ -249,9 +250,15 @@ pub enum UserInput { text_elements: Vec, }, Image { + #[serde(default)] + #[ts(optional)] + detail: Option, url: String, }, LocalImage { + #[serde(default)] + #[ts(optional)] + detail: Option, path: PathBuf, }, Skill { @@ -274,8 +281,11 @@ impl UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Image { url, detail } => CoreUserInput::Image { + image_url: url, + detail, + }, + UserInput::LocalImage { path, detail } => CoreUserInput::LocalImage { path, detail }, UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, } @@ -292,8 +302,11 @@ impl From for UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Image { image_url, detail } => UserInput::Image { + url: image_url, + detail, + }, + CoreUserInput::LocalImage { path, detail } => UserInput::LocalImage { path, detail }, CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, _ => unreachable!("unsupported user input variant"), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ddd7bf1a38..5b137c1091 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2137,6 +2137,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "after rollback".to_string(), @@ -3210,6 +3211,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }), ); } 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 c2e6b9a55e..f9841421ca 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 @@ -214,6 +214,7 @@ mod thread_processor_behavior_tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]; let active_turn = Turn { diff --git a/codex-rs/app-server/src/request_processors/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs index b19c4a61a0..b8c65645fc 100644 --- a/codex-rs/app-server/src/request_processors/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -152,6 +152,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "first answer".to_string(), @@ -167,6 +168,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), ] } 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 fa143254a5..c8e3e179fb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1323,6 +1323,7 @@ fn store_history_items() -> Vec { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))] } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index afc37d73c4..844d9ab1bb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; @@ -62,11 +63,13 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; @@ -84,6 +87,11 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +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, +]; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -91,6 +99,107 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .is_some_and(|body| body.contains(text)) } +async fn run_local_image_turn(detail: Option) -> Result> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the strict matcher does not currently + // cover image-bearing request payloads. + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + std::fs::write(&image_path, TINY_PNG_BYTES)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::LocalImage { + path: image_path, + detail, + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + received_response_input_images(&server).await +} + +async fn received_response_input_images(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut input_images = Vec::new(); + + for request in requests { + if !request.url.path().ends_with("/responses") { + continue; + } + let body = request + .body_json::() + .context("request body should be JSON")?; + let Some(input) = body.get("input").and_then(Value::as_array) else { + continue; + }; + + for item in input { + if item.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(Value::as_array) else { + continue; + }; + input_images.extend( + content + .iter() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) + .cloned(), + ); + } + } + + Ok(input_images) +} + #[tokio::test] async fn turn_start_sends_originator_header() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -554,6 +663,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { thread_id: thread.id.clone(), input: vec![V2UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }], ..Default::default() }) @@ -1465,63 +1575,27 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> } #[tokio::test] -async fn turn_start_accepts_local_image_input() -> Result<()> { - // Two Codex turns hit the mock model (session start + turn/start). - let responses = vec![ - create_final_assistant_message_sse_response("Done")?, - create_final_assistant_message_sse_response("Done")?, - ]; - // Use the unchecked variant because the request payload includes a LocalImage - // which the strict matcher does not currently cover. - let server = create_mock_responses_server_sequence_unchecked(responses).await; +async fn turn_start_defaults_local_image_detail_to_high() -> Result<()> { + let input_images = run_local_image_turn(/*detail*/ None).await?; - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::default(), - )?; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("high") + ); - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(()) +} - let thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; +#[tokio::test] +async fn turn_start_forwards_custom_local_image_detail() -> Result<()> { + let input_images = run_local_image_turn(Some(ImageDetail::Original)).await?; - let image_path = codex_home.path().join("image.png"); - // No need to actually write the file; we just exercise the input path. - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![V2UserInput::LocalImage { path: image_path }], - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let TurnStartResponse { turn } = to_response::(turn_resp)?; - assert!(!turn.id.is_empty()); - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("original") + ); Ok(()) } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 87ac5d6676..7ffdff0011 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1646,7 +1646,7 @@ async fn run_debug_prompt_input_command( .images .into_iter() .chain(cmd.images) - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect::>(); if let Some(prompt) = cmd.prompt.or(interactive.prompt) { input.push(UserInput::Text { diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 0c2813e51a..18d3c4555c 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -24,7 +24,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Global helpers: - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "high" | "original" | null } | ImageContent, detail?: "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs index 0ac3a03770..ae92639cc0 100644 --- a/codex-rs/code-mode/src/response.rs +++ b/codex-rs/code-mode/src/response.rs @@ -4,8 +4,6 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { - Auto, - Low, High, Original, } diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs index 8d76a832d3..865b5a569d 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -71,14 +71,10 @@ pub(super) fn normalize_output_image( Some(detail) => { let normalized = detail.to_ascii_lowercase(); Some(match normalized.as_str() { - "auto" => ImageDetail::Auto, - "low" => ImageDetail::Low, "high" => ImageDetail::High, "original" => ImageDetail::Original, _ => { - return Err( - "image detail must be one of: auto, low, high, original".to_string() - ); + return Err("image detail must be one of: high, original".to_string()); } }) } @@ -160,7 +156,7 @@ fn parse_mcp_output_image( .and_then(JsonValue::as_object) .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(JsonValue::as_str) - .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) + .filter(|detail| matches!(*detail, "high" | "original")) .map(str::to_string); Ok((image_url, detail)) } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 44e4be4939..1544f84f36 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1308,7 +1308,7 @@ image({ image( { image_url: "https://example.com/image.jpg", - detail: "low", + detail: "high", }, "original", ); @@ -1348,7 +1348,7 @@ image( mimeType: "image/png", _meta: { "codex/imageDetail": "original" }, }, - "low", + "high", ); "# .to_string(), @@ -1364,7 +1364,7 @@ image( cell_id: "1".to_string(), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::Low), + detail: Some(crate::ImageDetail::High), }], stored_values: HashMap::new(), error_text: None, @@ -1372,6 +1372,36 @@ image( ); } + #[tokio::test] + async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "low", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: Vec::new(), + stored_values: HashMap::new(), + error_text: Some("image detail must be one of: high, original".to_string()), + } + ); + } + #[tokio::test] async fn image_helper_rejects_raw_mcp_result_container() { let service = CodeModeService::new(); diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 329c6048b0..71bc026a13 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1235,7 +1235,9 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { .map(|item| match item { UserInput::Text { text, .. } => text.clone(), UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::LocalImage { path, .. } => { + format!("[local_image:{}]", path.display()) + } UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), _ => "[input]".to_string(), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index e7c79e6dd2..d3cae7feed 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -90,9 +90,10 @@ fn parse_user_message(message: &[ContentItem]) -> Option { text_elements: Vec::new(), }); } - ContentItem::InputImage { image_url, .. } => { + ContentItem::InputImage { image_url, detail } => { content.push(UserInput::Image { image_url: image_url.clone(), + detail: *detail, }); } ContentItem::OutputText { text } => { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index a70b2a69b0..c8311440eb 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -48,8 +48,14 @@ fn parses_user_message_with_text_and_two_images() { text: "Hello world".to_string(), text_elements: Vec::new(), }, - UserInput::Image { image_url: img1 }, - UserInput::Image { image_url: img2 }, + UserInput::Image { + image_url: img1, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + UserInput::Image { + image_url: img2, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ]; assert_eq!(user.content, expected_content); } @@ -87,7 +93,10 @@ fn skips_local_image_label_text() { match turn_item { TurnItem::UserMessage(user) => { let expected_content = vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, UserInput::Text { text: user_text, text_elements: Vec::new(), @@ -165,7 +174,10 @@ fn skips_unnamed_image_label_text() { match turn_item { TurnItem::UserMessage(user) => { let expected_content = vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, UserInput::Text { text: user_text, text_elements: Vec::new(), diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 699e06fe67..b84d6dd3eb 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -72,6 +72,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }; diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 143b23d3a3..aa403316e0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -143,6 +143,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -209,6 +210,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -237,6 +239,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(rolled_back_context_item), @@ -307,6 +310,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -335,6 +339,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::ResponseItem(turn_two_user), @@ -397,6 +402,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -425,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::ResponseItem(turn_two_user), @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -603,6 +611,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(only_context_item), @@ -655,6 +664,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -727,6 +737,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item.clone()), @@ -753,6 +764,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { @@ -884,6 +896,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(current_context_item), @@ -952,6 +965,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), // Compaction clears baseline until a later TurnContextItem re-establishes it. @@ -1065,6 +1079,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1091,6 +1106,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::EventMsg(EventMsg::TurnAborted( @@ -1176,6 +1192,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1202,6 +1219,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::EventMsg(EventMsg::TurnAborted( @@ -1296,6 +1314,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1322,6 +1341,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { @@ -1372,6 +1392,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1450,6 +1471,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1476,6 +1498,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index aa89efe1d4..cad702bca7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2364,6 +2364,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item.clone()), @@ -2558,6 +2559,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -2584,6 +2586,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(rolled_back_context_item), @@ -2666,6 +2669,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(first_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 1 user")), @@ -2709,6 +2713,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(TurnContextItem { turn_id: Some(rolled_back_turn_id.clone()), @@ -2763,6 +2768,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 1 user")), @@ -2787,6 +2793,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 2 user")), @@ -2811,6 +2818,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item), RolloutItem::ResponseItem(user_message("turn 3 user")), @@ -7521,6 +7529,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]) .await; @@ -7946,6 +7955,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() images, text_elements, local_images, + .. }) if message == "late pending input" && images == Some(Vec::new()) && text_elements.is_empty() diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 9fedff9470..956486f182 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1127,6 +1127,7 @@ fn completed_legacy_event_history_is_not_mid_turn() { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "done".to_string(), @@ -1154,6 +1155,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), ]); diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs index e20cf6a071..133d6e2fc1 100644 --- a/codex-rs/core/src/tools/code_mode/response_adapter.rs +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -17,8 +17,6 @@ impl IntoProtocol for CodeModeImageDetail { fn into_protocol(self) -> ImageDetail { let value = self; match value { - CodeModeImageDetail::Auto => ImageDetail::Auto, - CodeModeImageDetail::Low => ImageDetail::Low, CodeModeImageDetail::High => ImageDetail::High, CodeModeImageDetail::Original => ImageDetail::Original, } diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 7ad6a79829..c3e6792c06 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -60,6 +60,7 @@ struct ViewImageArgs { #[derive(Clone, Copy, Eq, PartialEq)] enum ViewImageDetail { + High, Original, } @@ -114,16 +115,15 @@ impl ToolExecutor for ViewImageHandler { environment_id, detail, } = parse_arguments(&arguments)?; - // `view_image` accepts only its documented detail values: omit - // `detail` for the default path or set it to `original`. - // Other string values remain invalid rather than being silently - // reinterpreted. + // `high` is the explicit spelling of the default resized path. + // Other string values remain invalid rather than being silently reinterpreted. let detail = match detail.as_deref() { None => None, + Some("high") => Some(ViewImageDetail::High), Some("original") => Some(ViewImageDetail::Original), Some(detail) => { return Err(FunctionCallError::RespondToModel(format!( - "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `{detail}`" + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `{detail}`" ))); } }; @@ -175,11 +175,11 @@ impl ToolExecutor for ViewImageHandler { } else { PromptImageMode::ResizeToFit }; - let image_detail = Some(if use_original_detail { + let image_detail = if use_original_detail { ImageDetail::Original } else { DEFAULT_IMAGE_DETAIL - }); + }; let image = load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| { @@ -208,7 +208,7 @@ impl CoreToolRuntime for ViewImageHandler {} pub struct ViewImageOutput { image_url: String, - image_detail: Option, + image_detail: ImageDetail, } impl ToolOutput for ViewImageOutput { @@ -224,7 +224,7 @@ impl ToolOutput for ViewImageOutput { let body = FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage { image_url: self.image_url.clone(), - detail: self.image_detail, + detail: Some(self.image_detail), }]); let output = FunctionCallOutputPayload { body, @@ -263,7 +263,7 @@ mod tests { fn code_mode_result_returns_image_url_object() { let output = ViewImageOutput { image_url: "data:image/png;base64,AAA".to_string(), - image_detail: Some(DEFAULT_IMAGE_DETAIL), + image_detail: DEFAULT_IMAGE_DETAIL, }; let result = output.code_mode_result(&ToolPayload::Function { @@ -317,4 +317,68 @@ mod tests { "{message}" ); } + + #[tokio::test] + async fn handle_rejects_unsupported_detail() { + let (session, turn) = make_session_and_context().await; + + let result = ViewImageHandler::default() + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-view-image".to_string(), + tool_name: codex_tools::ToolName::plain("view_image"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ "path": "image.png", "detail": "low" }).to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(message)) = result else { + panic!("expected unsupported detail error"); + }; + assert_eq!( + message, + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handle_accepts_explicit_high_detail() { + let (session, mut turn) = make_session_and_context().await; + let image_dir = tempfile::tempdir().expect("create image temp dir"); + let image_cwd = image_dir.abs(); + + turn.environments + .turn_environments + .first_mut() + .expect("default local turn environment") + .cwd = image_cwd.clone(); + let image_path = image_cwd.join("image.png"); + std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); + turn.permission_profile = PermissionProfile::Disabled; + + let result = ViewImageHandler::default() + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-view-image".to_string(), + tool_name: codex_tools::ToolName::plain("view_image"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ "path": "image.png", "detail": "high" }).to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(message)) = result else { + panic!("expected image processing error"); + }; + assert!(message.contains("unable to process image"), "{message}"); + } } diff --git a/codex-rs/core/src/tools/handlers/view_image_spec.rs b/codex-rs/core/src/tools/handlers/view_image_spec.rs index 7d1422a037..662cd639a6 100644 --- a/codex-rs/core/src/tools/handlers/view_image_spec.rs +++ b/codex-rs/core/src/tools/handlers/view_image_spec.rs @@ -20,9 +20,12 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { if options.can_request_original_image_detail { properties.insert( "detail".to_string(), - JsonSchema::string(Some( - "Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), - )), + JsonSchema::string_enum( + vec![json!("high"), json!("original")], + Some( + "Optional detail override. Supported values are `high` and `original`; omit this field for default high resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), + ), + ), ); } if options.include_environment_id { @@ -55,8 +58,9 @@ fn view_image_output_schema() -> Value { "description": "Data URL for the loaded image." }, "detail": { - "type": ["string", "null"], - "description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`." + "type": "string", + "enum": ["high", "original"], + "description": "Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved." } }, "required": ["image_url", "detail"], diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 063d75e6e8..333efe6f06 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -711,13 +711,14 @@ fn view_image_tool_includes_detail_with_original_detail_support() { }; let (properties, _) = expect_object_schema(parameters); assert!(properties.contains_key("detail")); - let description = expect_string_description( - properties - .get("detail") - .expect("view_image detail should include a description"), - ); - assert!(description.contains("only supported value is `original`")); - assert!(description.contains("omit this field for default resized behavior")); + let detail_schema = properties + .get("detail") + .expect("view_image detail should include a description"); + let description = expect_string_description(detail_schema); + let expected = vec![json!("high"), json!("original")]; + assert_eq!(detail_schema.enum_values.as_ref(), Some(&expected)); + assert!(description.contains("Supported values are `high` and `original`")); + assert!(description.contains("omit this field for default high resized behavior")); } #[test] @@ -2239,7 +2240,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { assert_eq!( description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.\n detail: string | null;\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" + "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" ); } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ff7640f05e..f117631c4a 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2466,7 +2466,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.\n detail: string | null;\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 68ddf1691b..be3423a85e 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3272,6 +3272,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess items: vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "USER_THREE".to_string(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 720ae10599..15197042fa 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -556,7 +556,10 @@ async fn assert_remote_manual_compact_request_parity( .submit(Op::UserInput { environments: None, items: vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: None, + }, UserInput::Text { text: "TURN_FOUR_IMAGE_USER".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 96616fa9cb..32c13b0b27 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -621,6 +621,7 @@ fn user_input_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec anyhow::Resu items: vec![ UserInput::LocalImage { path: abs_path.clone(), + detail: None, }, UserInput::Text { text: "pasted image".to_string(), @@ -208,6 +209,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> items: vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "dropped image".to_string(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 21ab43f10d..adce6e36eb 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -439,6 +439,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "first turn".to_string(), diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0b89a9cfba..5151d4e55d 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -88,6 +88,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index cb545df351..ac916ba66d 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -64,6 +64,7 @@ fn resume_history( images: None, local_images: vec![], text_elements: vec![], + ..Default::default() })), RolloutItem::TurnContext(turn_ctx), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 0cc7d07b16..ab0b4c27ea 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -168,6 +168,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }, ]; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5d5673b011..d78e8761fd 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -201,6 +201,7 @@ async fn assert_user_turn_local_image_resizes_to( &test, vec![UserInput::LocalImage { path: abs_path.clone(), + detail: None, }], session_model, )) @@ -814,7 +815,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho .expect("output text present"); assert_eq!( output_text, - "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `low`" + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`" ); assert!( @@ -1487,6 +1488,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> &test, vec![UserInput::LocalImage { path: abs_path.clone(), + detail: None, }], session_model, )) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1004124ba5..3e2cf21051 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -637,7 +637,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let mut items: Vec = imgs .into_iter() .chain(args.images.iter().cloned()) - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect(); items.push(UserInput::Text { text: prompt_text.clone(), @@ -657,7 +657,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let prompt_text = resolve_root_prompt(root_prompt); let mut items: Vec = imgs .into_iter() - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect(); items.push(UserInput::Text { text: prompt_text.clone(), diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 8f34f6ba5a..3682a4f7f8 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -81,6 +81,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec Some(image_url.clone()), + UserInput::Image { image_url, .. } => Some(image_url.clone()), _ => None, }) .collect() } + pub fn image_details(&self) -> Vec> { + trim_trailing_default_image_details( + self.content + .iter() + .filter_map(|c| match c { + UserInput::Image { detail, .. } => Some(*detail), + _ => None, + }) + .collect(), + ) + } + pub fn local_image_paths(&self) -> Vec { self.content .iter() .filter_map(|c| match c { - UserInput::LocalImage { path } => Some(path.clone()), + UserInput::LocalImage { path, .. } => Some(path.clone()), _ => None, }) .collect() } + + pub fn local_image_details(&self) -> Vec> { + trim_trailing_default_image_details( + self.content + .iter() + .filter_map(|c| match c { + UserInput::LocalImage { detail, .. } => Some(*detail), + _ => None, + }) + .collect(), + ) + } +} + +fn trim_trailing_default_image_details( + mut details: Vec>, +) -> Vec> { + while matches!(details.last(), Some(None)) { + details.pop(); + } + details } impl HookPromptItem { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2706a47c82..48a18a5cd9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -721,8 +721,6 @@ pub enum ContentItem { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { - Auto, - Low, High, Original, } @@ -1064,8 +1062,13 @@ pub fn local_image_content_items_with_label_number( path: &std::path::Path, file_bytes: Vec, label_number: Option, - mode: PromptImageMode, + detail: ImageDetail, ) -> Vec { + let mode = match detail { + ImageDetail::Original => PromptImageMode::Original, + ImageDetail::High => PromptImageMode::ResizeToFit, + }; + match load_for_prompt_bytes(path, file_bytes, mode) { Ok(image) => { let mut items = Vec::with_capacity(3); @@ -1076,7 +1079,7 @@ pub fn local_image_content_items_with_label_number( } items.push(ContentItem::InputImage { image_url: image.into_data_url(), - detail: Some(DEFAULT_IMAGE_DETAIL), + detail: Some(detail), }); if label_number.is_some() { items.push(ContentItem::InputText { @@ -1221,29 +1224,31 @@ impl From> for ResponseInputItem { .into_iter() .flat_map(|c| match c { UserInput::Text { text, .. } => vec![ContentItem::InputText { text }], - UserInput::Image { image_url } => { + UserInput::Image { image_url, detail } => { image_index += 1; + let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); vec![ ContentItem::InputText { text: image_open_tag_text(), }, ContentItem::InputImage { image_url, - detail: Some(DEFAULT_IMAGE_DETAIL), + detail: Some(detail), }, ContentItem::InputText { text: image_close_tag_text(), }, ] } - UserInput::LocalImage { path } => { + UserInput::LocalImage { path, detail } => { image_index += 1; + let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); match std::fs::read(&path) { Ok(file_bytes) => local_image_content_items_with_label_number( &path, file_bytes, Some(image_index), - PromptImageMode::ResizeToFit, + detail, ), Err(err) => vec![local_image_error_placeholder(&path, err)], } @@ -1587,8 +1592,6 @@ fn convert_mcp_content_to_items( .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(serde_json::Value::as_str) .and_then(|detail| match detail { - "auto" => Some(ImageDetail::Auto), - "low" => Some(ImageDetail::Low), "high" => Some(ImageDetail::High), "original" => Some(ImageDetail::Original), _ => None, @@ -1633,6 +1636,14 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + // A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate + // file paths, which break under Bazel sandboxing. + 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, + ]; + #[test] fn response_input_message_conversion_preserves_phase() { let item = ResponseItem::from(ResponseInputItem::Message { @@ -2545,6 +2556,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::Image { image_url: image_url.clone(), + detail: None, }]); match item { @@ -2569,6 +2581,31 @@ mod tests { Ok(()) } + #[test] + fn image_user_input_preserves_requested_detail() -> Result<()> { + let image_url = "data:image/png;base64,abc".to_string(); + + let item = ResponseInputItem::from(vec![UserInput::Image { + image_url: image_url.clone(), + detail: Some(ImageDetail::Original), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!( + content.get(1), + Some(&ContentItem::InputImage { + image_url, + detail: Some(ImageDetail::Original), + }) + ); + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } + #[test] fn tool_search_call_roundtrips() -> Result<()> { let parsed: ResponseItem = serde_json::from_str( @@ -2737,20 +2774,17 @@ mod tests { let image_url = "data:image/png;base64,abc".to_string(); let dir = tempdir()?; let local_path = dir.path().join("local.png"); - // A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which - // break under Bazel sandboxing. - 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, - ]; std::fs::write(&local_path, TINY_PNG_BYTES)?; let item = ResponseInputItem::from(vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, + }, + UserInput::LocalImage { + path: local_path, + detail: None, }, - UserInput::LocalImage { path: local_path }, ]); match item { @@ -2797,6 +2831,33 @@ mod tests { Ok(()) } + #[test] + fn local_image_user_input_preserves_requested_detail() -> Result<()> { + let dir = tempdir()?; + let local_path = dir.path().join("local.png"); + std::fs::write(&local_path, TINY_PNG_BYTES)?; + + let item = ResponseInputItem::from(vec![UserInput::LocalImage { + path: local_path, + detail: Some(ImageDetail::Original), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert!(matches!( + content.get(1), + Some(ContentItem::InputImage { + detail: Some(ImageDetail::Original), + .. + }) + )); + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } + #[test] fn local_image_read_error_adds_placeholder() -> Result<()> { let dir = tempdir()?; @@ -2804,6 +2865,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: missing_path.clone(), + detail: None, }]); match item { @@ -2838,6 +2900,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: json_path.clone(), + detail: None, }]); match item { @@ -2875,6 +2938,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: svg_path.clone(), + detail: None, }]); match item { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index fac2616d04..94753780cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -34,6 +34,7 @@ use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; +use crate::models::ImageDetail; use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; @@ -2225,7 +2226,7 @@ pub struct AgentMessageEvent { pub memory_citation: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { pub message: String, /// Image URLs sourced from `UserInput::Image`. These are safe @@ -2233,11 +2234,19 @@ pub struct UserMessageEvent { /// the model. #[serde(skip_serializing_if = "Option::is_none")] pub images: Option>, + /// Detail hints for `images`, indexed in parallel. Missing entries imply + /// default image detail behavior. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub image_details: Vec>, /// Local file paths sourced from `UserInput::LocalImage`. These are kept so /// the UI can reattach images when editing history, and should not be sent /// to the model or treated as API-ready URLs. #[serde(default)] pub local_images: Vec, + /// Detail hints for `local_images`, indexed in parallel. Missing entries + /// imply default image detail behavior. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub local_image_details: Vec>, /// UI-defined spans within `message` used to render or persist special elements. #[serde(default)] pub text_elements: Vec, @@ -5133,6 +5142,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }; let json_event = serde_json::to_value(event)?; @@ -5148,6 +5158,62 @@ mod tests { Ok(()) } + #[test] + fn user_message_event_deserializes_without_image_detail_fields() -> Result<()> { + let event: UserMessageEvent = serde_json::from_value(json!({ + "message": "hello", + "images": ["https://example.com/image.png"], + "local_images": ["/tmp/local.png"], + "text_elements": [], + }))?; + + assert_eq!(event.message, "hello"); + assert_eq!( + event.images, + Some(vec!["https://example.com/image.png".to_string()]) + ); + assert_eq!(event.image_details, Vec::>::new()); + assert_eq!(event.local_images, vec![PathBuf::from("/tmp/local.png")]); + assert_eq!(event.local_image_details, Vec::>::new()); + assert_eq!(event.text_elements, Vec::new()); + + Ok(()) + } + + #[test] + fn user_message_item_legacy_event_preserves_image_details() { + let local_path = PathBuf::from("/tmp/local.png"); + let item = UserMessageItem::new(&[ + crate::user_input::UserInput::Image { + image_url: "https://example.com/first.png".to_string(), + detail: Some(ImageDetail::Original), + }, + crate::user_input::UserInput::Image { + image_url: "https://example.com/second.png".to_string(), + detail: None, + }, + crate::user_input::UserInput::LocalImage { + path: local_path.clone(), + detail: Some(ImageDetail::Original), + }, + ]); + + let EventMsg::UserMessage(event) = item.as_legacy_event() else { + panic!("expected user message event"); + }; + + assert_eq!( + event.images, + Some(vec![ + "https://example.com/first.png".to_string(), + "https://example.com/second.png".to_string(), + ]) + ); + assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]); + assert_eq!(event.local_images, vec![local_path]); + assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]); + } + #[test] fn turn_aborted_event_deserializes_without_turn_id() -> Result<()> { let event: EventMsg = serde_json::from_value(json!({ diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 4ed112df8d..ce4cf99eba 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -3,6 +3,8 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +use crate::models::ImageDetail; + /// Conservative cap so one user message cannot monopolize a large context window. pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20; @@ -21,11 +23,21 @@ pub enum UserInput { text_elements: Vec, }, /// Pre‑encoded data: URI image. - Image { image_url: String }, + Image { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, /// Local image path provided by the user. This will be converted to an /// `Image` variant (base64 data URL) during request serialization. - LocalImage { path: std::path::PathBuf }, + LocalImage { + path: std::path::PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, /// Skill selected by the user (name + path to SKILL.md). Skill { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index ede7f720a9..8199f290d8 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -114,6 +114,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }, ]; @@ -404,6 +405,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]) .await?; diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 22f9a73a4e..bcd395d820 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1404,6 +1404,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), }; writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index d815d444ce..886d267e4f 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -205,6 +205,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -225,6 +226,7 @@ mod tests { images: Some(vec!["https://example.com/image.png".to_string()]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -248,6 +250,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -287,6 +290,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &user_item, "test-provider"); diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index ab53e12bbe..f6a48a400b 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1036,6 +1036,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })) } diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 37afda8bc2..edc78a36c2 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -546,6 +546,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() } } diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs index 37086f691d..145cda663c 100644 --- a/codex-rs/tools/src/image_detail.rs +++ b/codex-rs/tools/src/image_detail.rs @@ -16,7 +16,7 @@ pub fn normalize_output_image_detail( Some(ImageDetail::Original) } Some(ImageDetail::Original) | None => None, - Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) => detail, + Some(ImageDetail::High) => Some(ImageDetail::High), } } diff --git a/codex-rs/tools/src/image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs index 393a962ac4..919537acf7 100644 --- a/codex-rs/tools/src/image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -70,10 +70,6 @@ fn explicit_original_is_dropped_without_model_support() { fn explicit_non_original_detail_is_preserved() { let model_info = model_info(); - assert_eq!( - normalize_output_image_detail(&model_info, Some(ImageDetail::Low)), - Some(ImageDetail::Low) - ); assert_eq!( normalize_output_image_detail(&model_info, Some(ImageDetail::High)), Some(ImageDetail::High) @@ -92,7 +88,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::Low), + detail: Some(ImageDetail::High), }, ]; @@ -110,7 +106,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::Low), + detail: Some(ImageDetail::High), }, ] ); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bbc1802d17..c107bc777f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4727,7 +4727,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { assert!(items.iter().any(|item| { matches!( item, - UserInput::Image { url } if url == &data_image_url + UserInput::Image { url, .. } if url == &data_image_url ) })); } diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 6e9860bb77..2ead61f3dc 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -164,12 +164,14 @@ impl ChatWidget { for image_url in &remote_image_urls { items.push(UserInput::Image { url: image_url.clone(), + detail: None, }); } for image in &local_images { items.push(UserInput::LocalImage { path: image.path.clone(), + detail: None, }); } diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 96477c8d38..3e8635c46a 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -58,7 +58,8 @@ async fn submission_preserves_text_elements_and_local_images() { assert_eq!( items[0], UserInput::LocalImage { - path: local_images[0].clone() + path: local_images[0].clone(), + detail: None, } ); assert_eq!( @@ -259,12 +260,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi items[0], UserInput::Image { url: remote_url.clone(), + detail: None, } ); assert_eq!( items[1], UserInput::LocalImage { path: local_images[0].clone(), + detail: None, } ); assert_eq!( @@ -342,6 +345,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { items, vec![UserInput::Image { url: remote_url.clone(), + detail: None, }] ); assert_eq!(summary, None); @@ -1157,9 +1161,11 @@ fn user_message_display_from_inputs_matches_flattened_user_message_shape() { }, UserInput::Image { url: "https://example.com/remote.png".to_string(), + detail: None, }, UserInput::LocalImage { path: local_image.clone(), + detail: None, }, UserInput::Skill { name: "demo".to_string(), @@ -1232,6 +1238,7 @@ async fn committed_user_message_with_hidden_prompt_context_renders_local_images( }, UserInput::LocalImage { path: local_image.clone(), + detail: None, }, ], ); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8282430306..1d1b19fdf5 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -119,6 +119,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { }, AppServerUserInput::LocalImage { path: local_images[0].clone(), + detail: None, }, ], ReplayKind::ResumeInitialMessages, @@ -188,6 +189,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { }, AppServerUserInput::Image { url: remote_image_urls[0].clone(), + detail: None, }, ], ReplayKind::ResumeInitialMessages, @@ -441,6 +443,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { "user-1", vec![AppServerUserInput::Image { url: remote_image_urls[0].clone(), + detail: None, }], ReplayKind::ResumeInitialMessages, ); @@ -496,6 +499,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { "user-1", vec![AppServerUserInput::LocalImage { path: local_images[0].clone(), + detail: None, }], ReplayKind::ResumeInitialMessages, ); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 8c16e0b929..48bc341f6b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -597,6 +597,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() vec![ UserInput::Image { url: "data:image/png;base64,placeholder".to_string(), + detail: None, }, UserInput::Text { text, diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index bf3f9e8fe6..cdcf650cb1 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -593,8 +593,8 @@ impl ChatWidget { ) }), ), - UserInput::Image { url } => remote_image_urls.push(url.clone()), - UserInput::LocalImage { path } => local_images.push(path.clone()), + UserInput::Image { url, .. } => remote_image_urls.push(url.clone()), + UserInput::LocalImage { path, .. } => local_images.push(path.clone()), UserInput::Skill { .. } | UserInput::Mention { .. } => {} } } diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs index ec7e165ba8..884ebfb646 100644 --- a/codex-rs/tui/src/ide_context/prompt.rs +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -269,6 +269,7 @@ mod tests { let mut items = vec![ UserInput::LocalImage { path: PathBuf::from("/tmp/screenshot.png"), + detail: None, }, UserInput::Text { text, @@ -288,6 +289,7 @@ mod tests { vec![ UserInput::LocalImage { path: PathBuf::from("/tmp/screenshot.png"), + detail: None, }, UserInput::Text { text: format!("{expected_prefix}Ask $figma"), From bbb5c2811dc6a38a6da6c6992bc57034da516d2c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 15:42:35 -0700 Subject: [PATCH 025/114] tui: pass active permission profiles through app commands (#22891) ## Why This continues the permissions migration by keeping the TUI command boundary aligned with the app-server protocol direction from #22795: callers should select a permission profile by id instead of passing a concrete `PermissionProfile` value around as the turn configuration. `AppCommand` is internal to the TUI, but it is the path that eventually becomes `thread/turn/start`, so carrying concrete profile details there made it too easy for UI code to keep relying on the old whole-profile replacement model. ## What changed - `AppCommand::UserTurn` and `AppCommand::OverrideTurnContext` now carry `Option` instead of `PermissionProfile`. - Composer submissions copy the active permission profile id from the current session snapshot; legacy snapshots intentionally submit no active profile id. - Permission preset UI events now carry only the active built-in profile id. The app derives the concrete built-in `PermissionProfile` internally only when updating its local config/status snapshot. - Permission presets expose their built-in active profile id, and preset selection preserves that id in both the immediate turn override and the local TUI config snapshot. - Turn routing sends `TurnPermissionsOverride::ActiveProfile` when an active id is present, and only falls back to the legacy sandbox projection for the remaining runtime override path. ## How to review Start with `codex-rs/tui/src/app_command.rs` to verify the command shape no longer exposes `PermissionProfile`. Then read `codex-rs/tui/src/app/thread_routing.rs` to verify the app-server turn-start conversion: active ids go through as ids, while the legacy sandbox fallback is still constrained to the existing runtime override case. Finally, check `codex-rs/tui/src/chatwidget/permission_popups.rs`, `codex-rs/tui/src/app/event_dispatch.rs`, `codex-rs/tui/src/app/config_persistence.rs`, and `codex-rs/utils/approval-presets/src/lib.rs` to see how preset selections stay id-only across TUI events while the local display/config mirror still gets a concrete built-in profile. ## Verification Latest local verification after the id-only `AppEvent` cleanup: - `cargo check -p codex-tui --tests` - `cargo test -p codex-tui permissions_selection_sends_approvals_reviewer_in_override_turn_context` - `cargo test -p codex-tui update_feature_flags_enabling_guardian` - `cargo test -p codex-utils-approval-presets` - `just fmt` - `just fix -p codex-tui -p codex-utils-approval-presets` Earlier in the same PR, before the final event-shape cleanup: - `cargo test -p codex-tui turn_permissions_` - `cargo test -p codex-tui submission_` - `cargo test -p codex-tui session_configured_syncs_widget_config_permissions_and_cwd` - `RUST_MIN_STACK=16777216 cargo test -p codex-tui` --- codex-rs/tui/src/app.rs | 17 +++++- codex-rs/tui/src/app/config_persistence.rs | 59 +++++++++++++------ codex-rs/tui/src/app/event_dispatch.rs | 38 +++++++----- codex-rs/tui/src/app/tests.rs | 39 ++++++++---- codex-rs/tui/src/app/thread_routing.rs | 28 ++++----- codex-rs/tui/src/app/thread_session_state.rs | 9 ++- codex-rs/tui/src/app_command.rs | 14 ++--- codex-rs/tui/src/app_event.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 1 + .../tui/src/chatwidget/input_submission.rs | 4 +- .../tui/src/chatwidget/permission_popups.rs | 22 +++---- codex-rs/tui/src/chatwidget/rate_limits.rs | 2 +- codex-rs/tui/src/chatwidget/service_tiers.rs | 2 +- codex-rs/tui/src/chatwidget/settings.rs | 8 ++- .../tui/src/chatwidget/settings_popups.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 + .../chatwidget/tests/composer_submission.rs | 32 ++++++---- .../src/chatwidget/tests/history_replay.rs | 7 ++- .../tui/src/chatwidget/tests/permissions.rs | 18 +++++- .../src/chatwidget/windows_sandbox_prompts.rs | 16 +++-- codex-rs/utils/approval-presets/src/lib.rs | 31 ++++++++++ 21 files changed, 243 insertions(+), 114 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fcaf4d2290..00e45f979b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -140,6 +140,8 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; @@ -150,6 +152,7 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_rollout::StateDbHandle; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_approval_presets::builtin_permission_profile_for_active_permission_profile; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -327,7 +330,7 @@ fn default_exec_approval_decisions( struct AutoReviewMode { approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, } /// Enabling the Auto-review experiment in the TUI should also switch the @@ -338,7 +341,17 @@ fn auto_review_mode() -> AutoReviewMode { AutoReviewMode { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, - permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + ), + } +} + +#[cfg(test)] +impl AutoReviewMode { + fn permission_profile(&self) -> PermissionProfile { + builtin_permission_profile_for_active_permission_profile(&self.active_permission_profile) + .expect("auto-review mode should use a built-in permission profile") } } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 8d02e6f1e4..4a410f8130 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -105,24 +105,41 @@ impl App { true } - pub(super) fn try_set_permission_profile_on_config( + pub(super) fn try_set_builtin_active_permission_profile_on_config( &mut self, config: &mut Config, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, user_message_prefix: &str, log_message: &str, - ) -> bool { + ) -> Option { + let Some(permission_profile) = + builtin_permission_profile_for_active_permission_profile(&active_permission_profile) + else { + tracing::warn!( + id = %active_permission_profile.id, + "{log_message}: unsupported active permission profile" + ); + self.chat_widget.add_error_message(format!( + "{user_message_prefix}: unsupported active permission profile `{}`", + active_permission_profile.id + )); + return None; + }; + if let Err(err) = config .permissions - .set_permission_profile(permission_profile) + .set_permission_profile_from_session_snapshot( + permission_profile.clone(), + Some(active_permission_profile), + ) { tracing::warn!(error = %err, "{log_message}"); self.chat_widget .add_error_message(format!("{user_message_prefix}: {err}")); - return false; + return None; } - true + Some(permission_profile) } pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) { @@ -149,6 +166,7 @@ impl App { let mut approval_policy_override = None; let mut approvals_reviewer_override = None; let mut permission_profile_override = None; + let mut active_permission_profile_override = None; let mut feature_updates_to_apply = Vec::with_capacity(updates.len()); // Auto-Review owns `approvals_reviewer`, but disabling the feature // from inside a profile should not silently clear a value configured at @@ -240,14 +258,16 @@ impl App { ) { continue; } - if !self.try_set_permission_profile_on_config( - &mut feature_config, - auto_review_preset.permission_profile.clone(), - "Failed to enable Auto-review", - "failed to set auto-review permission profile on staged config", - ) { + let Some(permission_profile) = self + .try_set_builtin_active_permission_profile_on_config( + &mut feature_config, + auto_review_preset.active_permission_profile.clone(), + "Failed to enable Auto-review", + "failed to set auto-review permission profile on staged config", + ) + else { continue; - } + }; feature_edits.extend([ ConfigEdit::SetPath { segments: scoped_segments("approval_policy"), @@ -259,7 +279,9 @@ impl App { }, ]); approval_policy_override = Some(auto_review_preset.approval_policy); - permission_profile_override = Some(auto_review_preset.permission_profile.clone()); + permission_profile_override = Some(permission_profile); + active_permission_profile_override = + Some(auto_review_preset.active_permission_profile.clone()); } next_config = feature_config; feature_updates_to_apply.push((feature, effective_enabled)); @@ -305,7 +327,10 @@ impl App { if let Some(permission_profile) = permission_profile_override_value.as_ref() && let Err(err) = self .chat_widget - .set_permission_profile(permission_profile.clone()) + .set_permission_profile_from_session_snapshot( + permission_profile.clone(), + active_permission_profile_override.clone(), + ) { tracing::error!( error = %err, @@ -333,7 +358,7 @@ impl App { /*cwd*/ None, approval_policy_override, approvals_reviewer_override, - permission_profile_override, + active_permission_profile_override, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, @@ -360,7 +385,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 811c24cc4d..16e25e4d16 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1111,7 +1111,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, @@ -1136,7 +1136,7 @@ impl App { /*cwd*/ None, Some(AskForApproval::from(preset.approval)), Some(self.config.approvals_reviewer), - Some(preset.permission_profile.clone()), + Some(preset.active_permission_profile.clone()), #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, @@ -1150,9 +1150,10 @@ impl App { self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy( AskForApproval::from(preset.approval), )); - self.app_event_tx.send(AppEvent::UpdatePermissionProfile( - preset.permission_profile.clone(), - )); + self.app_event_tx + .send(AppEvent::UpdateActivePermissionProfile( + preset.active_permission_profile.clone(), + )); let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), @@ -1400,25 +1401,30 @@ impl App { self.sync_active_thread_permission_settings_to_cached_session() .await; } - AppEvent::UpdatePermissionProfile(permission_profile) => { + AppEvent::UpdateActivePermissionProfile(active_permission_profile) => { + let mut config = self.config.clone(); + let Some(permission_profile) = self + .try_set_builtin_active_permission_profile_on_config( + &mut config, + active_permission_profile.clone(), + "Failed to set permission profile", + "failed to set active permission profile on app config", + ) + else { + return Ok(AppRunControl::Continue); + }; #[cfg(target_os = "windows")] let permission_profile_is_managed_restricted = managed_filesystem_sandbox_is_restricted(&permission_profile); let permission_profile_for_chat = permission_profile.clone(); - let mut config = self.config.clone(); - if !self.try_set_permission_profile_on_config( - &mut config, - permission_profile, - "Failed to set permission profile", - "failed to set permission profile on app config", - ) { - return Ok(AppRunControl::Continue); - } self.config = config; if let Err(err) = self .chat_widget - .set_permission_profile(permission_profile_for_chat) + .set_permission_profile_from_session_snapshot( + permission_profile_for_chat, + Some(active_permission_profile), + ) { tracing::warn!(%err, "failed to set permission profile on chat config"); self.chat_widget diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c107bc777f..a925b4d488 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1640,7 +1640,18 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< .config_ref() .permissions .permission_profile(), - &auto_review.permission_profile + &auto_review.permission_profile() + ); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(auto_review.active_permission_profile.clone()) + ); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + Some(auto_review.active_permission_profile.clone()) ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, @@ -1649,7 +1660,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< assert_eq!(app.runtime_approval_policy_override, None); assert_eq!( app.runtime_permission_profile_override, - Some(auto_review.permission_profile.clone()) + Some(auto_review.permission_profile()) ); assert_eq!( op_rx.try_recv(), @@ -1657,7 +1668,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -1719,7 +1730,10 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile(PermissionProfile::workspace_write())?; + .set_permission_profile_from_session_snapshot( + PermissionProfile::workspace_write(), + /*active_profile*/ None, + )?; app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) .await; @@ -1747,7 +1761,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1817,7 +1831,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review .config_ref() .permissions .permission_profile(), - &auto_review.permission_profile + &auto_review.permission_profile() ); assert_eq!( op_rx.try_recv(), @@ -1825,7 +1839,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -1882,7 +1896,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1941,7 +1955,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -2028,7 +2042,7 @@ guardian_approval = true cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -3139,7 +3153,10 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile(parent_permission_profile.clone()) + .set_permission_profile_from_session_snapshot( + parent_permission_profile.clone(), + /*active_profile*/ None, + ) .expect("test permission profile should be accepted"); app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 4f858f10e6..d270bb53ce 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -510,7 +510,7 @@ impl App { cwd, approval_policy, approvals_reviewer, - permission_profile, + active_permission_profile, model, effort, summary, @@ -590,7 +590,7 @@ impl App { approvals_reviewer.unwrap_or(config.approvals_reviewer); let permissions_override = Self::turn_permissions_override_from_config( config, - permission_profile, + active_permission_profile.as_ref(), self.runtime_permission_profile_override.as_ref(), ); app_server @@ -698,16 +698,14 @@ impl App { fn turn_permissions_override_from_config( config: &Config, - permission_profile: &PermissionProfile, + active_permission_profile: Option<&ActivePermissionProfile>, runtime_permission_profile_override: Option<&PermissionProfile>, ) -> TurnPermissionsOverride { - let effective_permission_profile = config.permissions.effective_permission_profile(); - if &effective_permission_profile == permission_profile - && let Some(active_permission_profile) = config.permissions.active_permission_profile() - { - return TurnPermissionsOverride::ActiveProfile(active_permission_profile); + if let Some(active_permission_profile) = active_permission_profile { + return TurnPermissionsOverride::ActiveProfile(active_permission_profile.clone()); } + let effective_permission_profile = config.permissions.effective_permission_profile(); let runtime_permission_profile_override = runtime_permission_profile_override.map(|profile| { profile @@ -718,9 +716,9 @@ impl App { }); if runtime_permission_profile_override .as_ref() - .is_some_and(|profile| profile == permission_profile) + .is_some_and(|profile| profile == &effective_permission_profile) { - return TurnPermissionsOverride::LegacySandbox(permission_profile.clone()); + return TurnPermissionsOverride::LegacySandbox(effective_permission_profile); } TurnPermissionsOverride::Preserve @@ -1506,12 +1504,12 @@ mod tests { #[tokio::test] async fn turn_permissions_use_active_profile_when_available() { let config = config_with_workspace_profile().await; - let permission_profile = config.permissions.effective_permission_profile(); + let active_permission_profile = config.permissions.active_permission_profile(); assert_eq!( App::turn_permissions_override_from_config( &config, - &permission_profile, + active_permission_profile.as_ref(), /*runtime_permission_profile_override*/ None, ), TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new( @@ -1527,12 +1525,10 @@ mod tests { .permissions .set_permission_profile(PermissionProfile::read_only()) .expect("read-only profile should be allowed"); - let permission_profile = config.permissions.effective_permission_profile(); assert_eq!( App::turn_permissions_override_from_config( - &config, - &permission_profile, + &config, /*active_permission_profile*/ None, /*runtime_permission_profile_override*/ None, ), TurnPermissionsOverride::Preserve @@ -1552,7 +1548,7 @@ mod tests { assert_eq!( App::turn_permissions_override_from_config( &config, - &effective_permission_profile, + /*active_permission_profile*/ None, Some(&permission_profile), ), TurnPermissionsOverride::LegacySandbox(effective_permission_profile) diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index a8d421563b..eb56325663 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -131,6 +131,7 @@ mod tests { use codex_app_server_protocol::PermissionProfileFileSystemPermissions; use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_config::types::ApprovalsReviewer; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -197,9 +198,14 @@ mod tests { codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core()); app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; let expected_permission_profile = PermissionProfile::workspace_write(); + let expected_active_permission_profile = + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); app.chat_widget.handle_thread_session(main_session.clone()); app.chat_widget - .set_permission_profile(expected_permission_profile.clone()) + .set_permission_profile_from_session_snapshot( + expected_permission_profile.clone(), + Some(expected_active_permission_profile.clone()), + ) .expect("set widget permission profile"); app.config .permissions @@ -213,6 +219,7 @@ mod tests { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile: expected_permission_profile, + active_permission_profile: Some(expected_active_permission_profile), ..main_session }; assert_eq!( diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 89fd2600f8..0b6484d015 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -16,7 +16,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; @@ -41,7 +41,7 @@ pub(crate) enum AppCommand { cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: Option, - permission_profile: PermissionProfile, + active_permission_profile: Option, model: String, effort: Option, summary: Option, @@ -54,7 +54,7 @@ pub(crate) enum AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, - permission_profile: Option, + active_permission_profile: Option, windows_sandbox_level: Option, model: Option, effort: Option>, @@ -142,7 +142,7 @@ impl AppCommand { items: Vec, cwd: PathBuf, approval_policy: AskForApproval, - permission_profile: PermissionProfile, + active_permission_profile: Option, model: String, effort: Option, summary: Option, @@ -156,7 +156,7 @@ impl AppCommand { cwd, approval_policy, approvals_reviewer: None, - permission_profile, + active_permission_profile, model, effort, summary, @@ -172,7 +172,7 @@ impl AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, - permission_profile: Option, + active_permission_profile: Option, windows_sandbox_level: Option, model: Option, effort: Option>, @@ -185,7 +185,7 @@ impl AppCommand { cwd, approval_policy, approvals_reviewer, - permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ab9055197b..57ec371749 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -43,7 +43,7 @@ use codex_features::Feature; use codex_plugin::PluginCapabilitySummary; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; -use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -742,8 +742,8 @@ pub(crate) enum AppEvent { /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), - /// Update the current permission profile in the running app and widget. - UpdatePermissionProfile(PermissionProfile), + /// Update the current built-in active permission profile in the running app and widget. + UpdateActivePermissionProfile(ActivePermissionProfile), /// Update the current approvals reviewer in the running app and widget. UpdateApprovalsReviewer(ApprovalsReviewer), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5c7d0194ef..207c547562 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -442,6 +442,7 @@ use crate::workspace_command::WorkspaceCommandRunner; use chrono::Local; use codex_app_server_protocol::AskForApproval; use codex_file_search::FileMatch; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 2ead61f3dc..2fd376f139 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -336,12 +336,12 @@ impl ChatWidget { None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, }; - let permission_profile = self.config.permissions.effective_permission_profile(); + let active_permission_profile = self.config.permissions.active_permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), AskForApproval::from(self.config.permissions.approval_policy.value()), - permission_profile, + active_permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index 6f2e1c4dbd..a82f3a6cdb 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -124,7 +124,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -134,7 +134,7 @@ impl ChatWidget { { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -142,7 +142,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -180,7 +180,7 @@ impl ChatWidget { ), actions: Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), "Auto-review".to_string(), ApprovalsReviewer::AutoReview, ), @@ -308,17 +308,16 @@ impl ChatWidget { pub(super) fn approval_preset_actions( approval: AskForApproval, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, label: String, approvals_reviewer: ApprovalsReviewer, ) -> Vec { vec![Box::new(move |tx| { - let permission_profile_clone = permission_profile.clone(); tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, Some(approval), Some(approvals_reviewer), - Some(permission_profile_clone.clone()), + Some(active_permission_profile.clone()), /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, @@ -328,7 +327,9 @@ impl ChatWidget { /*personality*/ None, ))); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); + tx.send(AppEvent::UpdateActivePermissionProfile( + active_permission_profile.clone(), + )); tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( @@ -385,7 +386,6 @@ impl ChatWidget { ) { let selected_name = preset.label.to_string(); let approval = AskForApproval::from(preset.approval); - let permission_profile = preset.permission_profile; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ @@ -402,7 +402,7 @@ impl ChatWidget { let mut accept_actions = Self::approval_preset_actions( approval, - permission_profile.clone(), + preset.active_permission_profile.clone(), selected_name.clone(), ApprovalsReviewer::User, ); @@ -412,7 +412,7 @@ impl ChatWidget { let mut accept_and_remember_actions = Self::approval_preset_actions( approval, - permission_profile, + preset.active_permission_profile, selected_name, ApprovalsReviewer::User, ); diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 6b5414536d..7eaca908c6 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -285,7 +285,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), Some(Some(default_effort)), diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index 1cdb52f224..a60af0a69e 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -107,7 +107,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 419070ef9e..a2125c3f47 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -17,13 +17,15 @@ impl ChatWidget { } } - /// Set the permission profile in the widget's config copy. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_permission_profile( + pub(crate) fn set_permission_profile_from_session_snapshot( &mut self, profile: PermissionProfile, + active_profile: Option, ) -> ConstraintResult<()> { - self.config.permissions.set_permission_profile(profile)?; + self.config + .permissions + .set_permission_profile_from_session_snapshot(profile, active_profile)?; self.refresh_status_surfaces(); Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index d718e70d79..2dcfefb73a 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -53,7 +53,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f86b896e5b..e0dda9eb5c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -149,6 +149,8 @@ pub(super) use codex_protocol::config_types::CollaborationMode; pub(super) use codex_protocol::config_types::ModeKind; pub(super) use codex_protocol::config_types::Personality; pub(super) use codex_protocol::config_types::ServiceTier; +pub(super) use codex_protocol::models::ActivePermissionProfile; +pub(super) use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; pub(super) use codex_protocol::models::FileSystemPermissions; pub(super) use codex_protocol::models::MessagePhase; pub(super) use codex_protocol::models::NetworkPermissions; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 3e8635c46a..381c5430a9 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -94,7 +94,7 @@ async fn submission_preserves_text_elements_and_local_images() { } #[tokio::test] -async fn submission_includes_configured_permission_profile() { +async fn submission_includes_configured_active_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); @@ -120,6 +120,7 @@ async fn submission_includes_configured_permission_profile() { }, } .into(); + let expected_active_permission_profile = ActivePermissionProfile::new("custom"); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, @@ -130,8 +131,8 @@ async fn submission_includes_configured_permission_profile() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), - active_permission_profile: None, + permission_profile: expected_permission_profile, + active_permission_profile: Some(expected_active_permission_profile.clone()), cwd: test_path_buf("/home/user/project").abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), @@ -150,17 +151,21 @@ async fn submission_includes_configured_permission_profile() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let permission_profile = match next_submit_op(&mut op_rx) { + let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { - permission_profile, .. - } => permission_profile, + active_permission_profile, + .. + } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, expected_permission_profile); + assert_eq!( + active_permission_profile, + Some(expected_active_permission_profile) + ); } #[tokio::test] -async fn submission_keeps_profile_when_legacy_projection_is_external() { +async fn submission_omits_active_permission_profile_for_legacy_snapshot() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); @@ -180,7 +185,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), + permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), runtime_workspace_roots: Vec::new(), @@ -197,13 +202,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { .set_composer_text("submit".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let permission_profile = match next_submit_op(&mut op_rx) { + let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { - permission_profile, .. - } => permission_profile, + active_permission_profile, + .. + } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, expected_permission_profile); + assert_eq!(active_permission_profile, None); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 1d1b19fdf5..7dca128448 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -296,8 +296,11 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { assert_eq!(&chat.config_ref().cwd, &expected_cwd); let updated_profile = PermissionProfile::workspace_write(); - chat.set_permission_profile(updated_profile.clone()) - .expect("set permission profile"); + chat.set_permission_profile_from_session_snapshot( + updated_profile.clone(), + /*active_profile*/ None, + ) + .expect("set permission profile"); assert_eq!( chat.config_ref().permissions.permission_profile(), &updated_profile, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 4c66041271..9928676e4f 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -742,7 +742,9 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context cwd: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - permission_profile: Some(PermissionProfile::workspace_write()), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), windows_sandbox_level: None, model: None, effort: None, @@ -752,6 +754,20 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context personality: None, } ); + + let active_permission_profile_update = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::UpdateActivePermissionProfile(active_permission_profile) => { + Some(active_permission_profile) + } + _ => None, + }) + .expect("expected UpdateActivePermissionProfile event"); + + assert_eq!( + active_permission_profile_update, + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 401e2a787d..d8ff59da48 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -42,10 +42,10 @@ impl ChatWidget { extra_count: usize, failed_scan: bool, ) { - let (approval, permission_profile) = match &preset { + let (approval, active_permission_profile) = match &preset { Some(p) => ( Some(AskForApproval::from(p.approval)), - Some(p.permission_profile.clone()), + Some(p.active_permission_profile.clone()), ), None => (None, None), }; @@ -110,10 +110,12 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { + if let (Some(approval), Some(active_permission_profile)) = + (approval, active_permission_profile.clone()) + { accept_actions.extend(Self::approval_preset_actions( approval, - permission_profile, + active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); @@ -124,10 +126,12 @@ impl ChatWidget { tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { + if let (Some(approval), Some(active_permission_profile)) = + (approval, active_permission_profile) + { accept_and_remember_actions.extend(Self::approval_preset_actions( approval, - permission_profile, + active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); diff --git a/codex-rs/utils/approval-presets/src/lib.rs b/codex-rs/utils/approval-presets/src/lib.rs index b254950500..c7ce9e578b 100644 --- a/codex-rs/utils/approval-presets/src/lib.rs +++ b/codex-rs/utils/approval-presets/src/lib.rs @@ -1,3 +1,7 @@ +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -12,6 +16,8 @@ pub struct ApprovalPreset { pub description: &'static str, /// Approval policy to apply. pub approval: AskForApproval, + /// Built-in permission profile selected by this preset. + pub active_permission_profile: ActivePermissionProfile, /// Permission profile to apply. pub permission_profile: PermissionProfile, } @@ -26,6 +32,9 @@ pub fn builtin_approval_presets() -> Vec { label: "Read Only", description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet.", approval: AskForApproval::OnRequest, + active_permission_profile: ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + ), permission_profile: PermissionProfile::read_only(), }, ApprovalPreset { @@ -33,6 +42,9 @@ pub fn builtin_approval_presets() -> Vec { label: "Default", description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. (Identical to Agent mode)", approval: AskForApproval::OnRequest, + active_permission_profile: ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + ), permission_profile: PermissionProfile::workspace_write(), }, ApprovalPreset { @@ -40,7 +52,26 @@ pub fn builtin_approval_presets() -> Vec { label: "Full Access", description: "Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.", approval: AskForApproval::Never, + active_permission_profile: ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + ), permission_profile: PermissionProfile::Disabled, }, ] } + +/// Return the concrete profile for one of the built-in active profile ids. +pub fn builtin_permission_profile_for_active_permission_profile( + active_permission_profile: &ActivePermissionProfile, +) -> Option { + if active_permission_profile.extends.is_some() { + return None; + } + + match active_permission_profile.id.as_str() { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY => Some(PermissionProfile::read_only()), + BUILT_IN_PERMISSION_PROFILE_WORKSPACE => Some(PermissionProfile::workspace_write()), + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS => Some(PermissionProfile::Disabled), + _ => None, + } +} From 579bb3b0155300cd8223ce73dddc354a9309ddfe Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:10:26 -0700 Subject: [PATCH 026/114] Simplify stop hook parsing --- codex-rs/hooks/src/events/stop.rs | 105 ++++++++++++------------------ 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index a7cf33e196..6afd36584d 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -78,6 +78,12 @@ struct StopHandlerData { continuation_fragments: Vec, } +#[derive(Debug, Clone, Copy)] +enum StopHookKind { + Stop, + SubagentStop, +} + pub(crate) fn preview( handlers: &[ConfiguredHandler], request: &StopRequest, @@ -113,7 +119,7 @@ pub(crate) async fn run( }; } - let (input_json, parse_completed) = match request.target { + let input_json = match request.target { StopHookTarget::Stop => { let input = StopCommandInput { session_id: request.session_id.to_string(), @@ -129,16 +135,7 @@ pub(crate) async fn run( ), }; match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - parse_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -172,16 +169,7 @@ pub(crate) async fn run( ), }; match serde_json::to_string(&input) { - Ok(input_json) => ( - input_json, - parse_subagent_stop_completed - as fn( - &ConfiguredHandler, - CommandRunResult, - Option, - ) - -> dispatcher::ParsedHandler, - ), + Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( @@ -221,39 +209,6 @@ fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_stop_completed( - handler, - run_result, - turn_id, - "Stop", - "stop", - output_parser::parse_stop, - ) -} - -fn parse_subagent_stop_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, -) -> dispatcher::ParsedHandler { - parse_stop_completed( - handler, - run_result, - turn_id, - "SubagentStop", - "subagent stop", - output_parser::parse_subagent_stop, - ) -} - -fn parse_stop_completed( - handler: &ConfiguredHandler, - run_result: CommandRunResult, - turn_id: Option, - hook_name: &str, - hook_label: &str, - parse_output: fn(&str) -> Option, ) -> dispatcher::ParsedHandler { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; @@ -262,6 +217,13 @@ fn parse_stop_completed( let mut should_block = false; let mut block_reason = None; let mut continuation_prompt = None; + let hook_kind = match handler.event_name { + HookEventName::Stop => StopHookKind::Stop, + HookEventName::SubagentStop => StopHookKind::SubagentStop, + event_name => { + panic!("expected stop hook event, got {event_name:?}"); + } + }; match run_result.error.as_deref() { Some(error) => { @@ -275,7 +237,12 @@ fn parse_stop_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = parse_output(&run_result.stdout) { + } else if let Some(parsed) = match hook_kind { + StopHookKind::Stop => output_parser::parse_stop(&run_result.stdout), + StopHookKind::SubagentStop => { + output_parser::parse_subagent_stop(&run_result.stdout) + } + } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -315,9 +282,11 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!( - "{hook_name} hook returned decision:block without a non-empty reason" - ), + text: match hook_kind { + StopHookKind::Stop => "Stop hook returned decision:block without a non-empty reason", + StopHookKind::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + } + .to_string(), }); } } @@ -325,7 +294,13 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!("hook returned invalid {hook_label} hook JSON output"), + text: match hook_kind { + StopHookKind::Stop => "hook returned invalid stop hook JSON output", + StopHookKind::SubagentStop => { + "hook returned invalid subagent stop hook JSON output" + } + } + .to_string(), }); } } @@ -343,9 +318,15 @@ fn parse_stop_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: format!( - "{hook_name} hook exited with code 2 but did not write a continuation prompt to stderr" - ), + text: match hook_kind { + StopHookKind::Stop => { + "Stop hook exited with code 2 but did not write a continuation prompt to stderr" + } + StopHookKind::SubagentStop => { + "SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr" + } + } + .to_string(), }); } } From ce0d7c465cf845b6ccd04e9a061d956869d8215e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:28:08 -0700 Subject: [PATCH 027/114] Remove stop hook kind helper --- codex-rs/hooks/src/events/stop.rs | 39 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 6afd36584d..24920c569b 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -78,12 +78,6 @@ struct StopHandlerData { continuation_fragments: Vec, } -#[derive(Debug, Clone, Copy)] -enum StopHookKind { - Stop, - SubagentStop, -} - pub(crate) fn preview( handlers: &[ConfiguredHandler], request: &StopRequest, @@ -217,9 +211,8 @@ fn parse_completed( let mut should_block = false; let mut block_reason = None; let mut continuation_prompt = None; - let hook_kind = match handler.event_name { - HookEventName::Stop => StopHookKind::Stop, - HookEventName::SubagentStop => StopHookKind::SubagentStop, + let hook_event_name = match handler.event_name { + HookEventName::Stop | HookEventName::SubagentStop => handler.event_name, event_name => { panic!("expected stop hook event, got {event_name:?}"); } @@ -237,11 +230,12 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = match hook_kind { - StopHookKind::Stop => output_parser::parse_stop(&run_result.stdout), - StopHookKind::SubagentStop => { + } else if let Some(parsed) = match hook_event_name { + HookEventName::Stop => output_parser::parse_stop(&run_result.stdout), + HookEventName::SubagentStop => { output_parser::parse_subagent_stop(&run_result.stdout) } + _ => unreachable!("validated stop hook event"), } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { @@ -282,9 +276,10 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => "Stop hook returned decision:block without a non-empty reason", - StopHookKind::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + text: match hook_event_name { + HookEventName::Stop => "Stop hook returned decision:block without a non-empty reason", + HookEventName::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", + _ => unreachable!("validated stop hook event"), } .to_string(), }); @@ -294,11 +289,12 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => "hook returned invalid stop hook JSON output", - StopHookKind::SubagentStop => { + text: match hook_event_name { + HookEventName::Stop => "hook returned invalid stop hook JSON output", + HookEventName::SubagentStop => { "hook returned invalid subagent stop hook JSON output" } + _ => unreachable!("validated stop hook event"), } .to_string(), }); @@ -318,13 +314,14 @@ fn parse_completed( status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: match hook_kind { - StopHookKind::Stop => { + text: match hook_event_name { + HookEventName::Stop => { "Stop hook exited with code 2 but did not write a continuation prompt to stderr" } - StopHookKind::SubagentStop => { + HookEventName::SubagentStop => { "SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr" } + _ => unreachable!("validated stop hook event"), } .to_string(), }); From 94cbf56ad096a229884a51f7bde7063a8f167c91 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Fri, 15 May 2026 16:33:06 -0700 Subject: [PATCH 028/114] Clarify stop hook target resolution --- codex-rs/core/src/hook_runtime.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 7be05d060d..926dc94213 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -308,6 +308,8 @@ pub(crate) async fn run_turn_stop_hooks( stop_hook_active: bool, last_assistant_message: Option, ) -> StopOutcome { + // Resolve the stop hook kind from the session source before building the + // request. Root turns run Stop; thread-spawned child turns run SubagentStop. let (target, transcript_path) = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, @@ -347,6 +349,8 @@ pub(crate) async fn run_turn_stop_hooks( parent_transcript_path, ) } + // Internal/synthetic subagents do not expose user-configured lifecycle + // hooks, so there is no Stop or SubagentStop request to dispatch. SessionSource::SubAgent(_) => return StopOutcome::default(), _ => (StopHookTarget::Stop, sess.hook_transcript_path().await), }; From 9025550709300cfa9cc0e562ace4491323ce86fc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 15 May 2026 17:10:15 -0700 Subject: [PATCH 029/114] app-server-protocol: remove PermissionProfile from API (#22924) ## Why The app server API should expose permission profile identity, not the lower-level runtime permission model. `PermissionProfile` is the compiled sandbox/network representation that the server uses internally; exposing it through app-server-protocol forces clients to understand details that should remain implementation-level. The API boundary should prefer `ActivePermissionProfile`: a stable profile id, plus future parent-profile metadata, that clients can pass back when they want to select the same active permissions. This also avoids schema generation collisions between the app-server v2 API type space and the core protocol model. Incidentally, while PR makes a number of changes to `command/exec`, note that we are hoping to deprecate this API in favor of `process/spawn`, so we don't need to be too finicky about these changes. ## What Changed - Removed `PermissionProfile` from the app-server-protocol API surface, including generated schema and TypeScript exports. - Changed `CommandExecParams.permissionProfile` to `ActivePermissionProfile`. - Resolve command exec profile ids through `ConfigManager` for the command cwd, matching turn override selection semantics. - Updated downstream TUI tests/helpers to use core permission types directly instead of app-server-protocol `PermissionProfile` shims. --- .../schema/json/ClientRequest.json | 345 +----------------- .../codex_app_server_protocol.schemas.json | 129 ------- .../codex_app_server_protocol.v2.schemas.json | 129 ------- .../schema/json/v2/CommandExecParams.json | 345 +----------------- .../schema/typescript/v2/PermissionProfile.ts | 7 - .../PermissionProfileFileSystemPermissions.ts | 6 - .../v2/PermissionProfileNetworkPermissions.ts | 5 - .../schema/typescript/v2/index.ts | 3 - codex-rs/app-server-protocol/src/export.rs | 12 +- .../src/protocol/common.rs | 2 +- .../src/protocol/v2/command_exec.rs | 6 +- .../src/protocol/v2/permissions.rs | 151 +------- .../src/protocol/v2/tests.rs | 43 --- codex-rs/app-server/README.md | 11 +- codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 + .../command_exec_processor.rs | 169 +++++---- .../command_exec_processor_tests.rs | 38 -- .../app-server/tests/suite/v2/command_exec.rs | 184 ++++++++-- codex-rs/protocol/src/models.rs | 4 + codex-rs/tui/src/additional_dirs.rs | 29 +- codex-rs/tui/src/app/thread_session_state.rs | 22 +- codex-rs/tui/src/app_server_session.rs | 31 +- .../chatwidget/tests/composer_submission.rs | 31 +- .../src/chatwidget/tests/history_replay.rs | 37 +- .../tui/src/chatwidget/tests/permissions.rs | 29 +- codex-rs/tui/src/history_cell/mod.rs | 5 +- codex-rs/tui/src/history_cell/session.rs | 9 +- codex-rs/tui/src/history_cell/tests.rs | 16 +- codex-rs/tui/src/permission_compat.rs | 22 +- codex-rs/tui/src/status/tests.rs | 54 ++- 31 files changed, 436 insertions(+), 1440 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts delete mode 100644 codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5fc44d4730..ad45893bbe 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -719,202 +739,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FsCopyParams": { "description": "Copy a file or directory tree on the host filesystem.", "properties": { @@ -1730,135 +1554,6 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 5813afcdb2..8bb77fae37 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 @@ -11619,135 +11619,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/v2/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 d14acc7a1b..01a8d6a820 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 @@ -8168,135 +8168,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..b6e939386b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { @@ -27,202 +47,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "NetworkAccess": { "enum": [ "restricted", @@ -230,135 +54,6 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SandboxPolicy": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts deleted file mode 100644 index 7642c27650..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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. -import type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; - -export type PermissionProfile = { "type": "managed", network: PermissionProfileNetworkPermissions, fileSystem: PermissionProfileFileSystemPermissions, } | { "type": "disabled" } | { "type": "external", network: PermissionProfileNetworkPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts deleted file mode 100644 index 29aeceb433..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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. -import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; - -export type PermissionProfileFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts deleted file mode 100644 index 0b25a769a9..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 PermissionProfileNetworkPermissions = { enabled: boolean, }; 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 beb1973bb9..5deeccd793 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -255,9 +255,6 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; -export type { PermissionProfile } from "./PermissionProfile"; -export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 0f9b33671b..d44b67825d 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2747,7 +2747,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k let _guard = TempDirGuard(output_dir.clone()); let path = output_dir.join("CommandExecParams.ts"); let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -import type { PermissionProfile } from "./PermissionProfile"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; export type CommandExecParams = {/** @@ -2770,12 +2770,12 @@ size?: CommandExecTerminalSize | null, /** */ sandboxPolicy?: SandboxPolicy | null, /** - * Optional full permissions profile for this command. + * Optional active permissions profile for this command. * * Defaults to the user's configured permissions when omitted. Cannot be * combined with `sandboxPolicy`. */ -permissionProfile?: PermissionProfile | null}; +permissionProfile?: ActivePermissionProfile | null}; "#; fs::write(&path, content)?; @@ -2789,11 +2789,13 @@ permissionProfile?: PermissionProfile | null}; let filtered = fs::read_to_string(&path)?; assert_eq!( - filtered.contains("permissionProfile?: PermissionProfile"), + filtered.contains("permissionProfile?: ActivePermissionProfile"), false ); assert_eq!( - filtered.contains(r#"import type { PermissionProfile } from "./PermissionProfile";"#), + filtered.contains( + r#"import type { ActivePermissionProfile } from "./ActivePermissionProfile";"# + ), false ); assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9681f70c34..1ab2743d6b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2954,7 +2954,7 @@ mod tests { env: None, size: None, sandbox_policy: None, - permission_profile: Some(v2::PermissionProfile::Disabled), + permission_profile: Some(v2::ActivePermissionProfile::read_only()), }, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index ff0cecf4f9..d476dcb90e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -1,4 +1,4 @@ -use super::PermissionProfile; +use super::ActivePermissionProfile; use super::SandboxPolicy; use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; @@ -100,13 +100,13 @@ pub struct CommandExecParams { /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Optional full permissions profile for this command. + /// Optional active permissions profile for this command. /// /// Defaults to the user's configured permissions when omitted. Cannot be /// combined with `sandboxPolicy`. #[experimental("command/exec.permissionProfile")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permission_profile: Option, } /// Final buffered result for `command/exec`. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index faf264411e..3299695497 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -7,14 +7,11 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -136,13 +133,6 @@ pub struct AdditionalNetworkPermissions { pub enabled: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - impl From for AdditionalNetworkPermissions { fn from(value: CoreNetworkPermissions) -> Self { Self { @@ -159,24 +149,6 @@ impl From for CoreNetworkPermissions { } } -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -317,116 +289,6 @@ impl From for CoreFileSystemSandboxEntry { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, -} - -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -440,6 +302,19 @@ pub struct ActivePermissionProfile { pub extends: Option, } +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, + } + } + + pub fn read_only() -> Self { + CoreActivePermissionProfile::read_only().into() + } +} + impl From for ActivePermissionProfile { fn from(value: CoreActivePermissionProfile) -> Self { Self { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 526cc22131..fb2a59bded 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -16,7 +16,6 @@ use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEn use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ImageDetail; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; @@ -506,48 +505,6 @@ fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { .expect_err("zero glob scan depth should fail deserialization"); } -#[test] -fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); -} - -#[test] -fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); -} - #[test] fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { let special_path = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8e3a23f0f7..9d0a509c4c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -926,14 +926,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "permissionProfile": { // optional; defaults to user config - "type": "managed", - "fileSystem": { "type": "restricted", "entries": [ - { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, - { "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" } - ] }, - "network": { "enabled": false } - }, + "permissionProfile": { "id": ":workspace", "extends": null }, // optional; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -952,7 +945,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin Notes: - Empty `command` arrays are rejected. -- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. +- Prefer `permissionProfile` for command permission overrides. It selects an active profile by id (for example `:read-only`, `:workspace`, or a user-defined `[permissions.]` profile) rather than accepting low-level filesystem/network permissions. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 79c077dec7..fc835b82ec 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -354,6 +354,7 @@ impl MessageProcessor { arg0_paths.clone(), Arc::clone(&config), outgoing.clone(), + config_manager.clone(), ); let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); let feedback_processor = FeedbackRequestProcessor::new( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 611678a713..d5853b56c5 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -356,6 +356,7 @@ use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +#[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 2ae11363a5..2b07588c8a 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -5,6 +5,7 @@ pub(crate) struct CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, command_exec_manager: CommandExecManager, } @@ -13,11 +14,13 @@ impl CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, ) -> Self { Self { arg0_paths, config, outgoing, + config_manager, command_exec_manager: CommandExecManager::default(), } } @@ -114,6 +117,13 @@ impl CommandExecRequestProcessor { "`permissionProfile` cannot be combined with `sandboxPolicy`", )); } + let permission_profile = if let Some(active_permission_profile) = permission_profile { + Some(PermissionProfileSelectionParams::new( + active_permission_profile.id, + )) + } else { + None + }; if size.is_some() && !tty { return Err(invalid_params("command/exec size requires tty: true")); @@ -159,28 +169,6 @@ impl CommandExecRequestProcessor { }, None => None, }; - let managed_network_requirements_enabled = - self.config.managed_network_requirements_enabled(); - let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec - .start_proxy( - self.config.permissions.permission_profile(), - /*policy_decider*/ None, - /*blocked_request_observer*/ None, - managed_network_requirements_enabled, - NetworkProxyAuditMetadata::default(), - ) - .await - { - Ok(started) => Some(started), - Err(err) => { - return Err(internal_error(format!( - "failed to start managed network proxy: {err}" - ))); - } - }, - None => None, - }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); let output_bytes_cap = if disable_output_cap { None @@ -205,47 +193,42 @@ impl CommandExecRequestProcessor { } else { self.config.cwd.clone() }; - let exec_params = ExecParams { - command, - cwd: cwd.clone(), - expiration, - capture_policy, - env, - network: started_network_proxy - .as_ref() - .map(codex_core::config::StartedNetworkProxy::proxy), - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level, - windows_sandbox_private_desktop: self - .config - .permissions - .windows_sandbox_private_desktop, - justification: None, - arg0: None, - }; - - let effective_permission_profile = if let Some(permission_profile) = permission_profile { - let permission_profile = - codex_protocol::models::PermissionProfile::from(permission_profile); - let (mut file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - let configured_file_system_sandbox_policy = - self.config.permissions.file_system_sandbox_policy(); - Self::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, + let ( + effective_permission_profile, + network_proxy_spec, + network_proxy_permission_profile, + managed_network_requirements_enabled, + ) = if let Some(permission_profile) = permission_profile { + let mut overrides = ConfigOverrides { + cwd: Some(cwd.to_path_buf()), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permission_profile), ); - let effective_permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .can_set_permission_profile(&effective_permission_profile) + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(self.config.cwd.to_path_buf()), + ) + .await .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; - effective_permission_profile + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid permission profile: {warning}" + ))); + } + ( + config.permissions.effective_permission_profile(), + config.permissions.network.clone(), + config.permissions.permission_profile().clone(), + config.managed_network_requirements_enabled(), + ) } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { self.config .permissions @@ -265,9 +248,57 @@ impl CommandExecRequestProcessor { .permissions .can_set_permission_profile(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - permission_profile + ( + permission_profile, + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + ) } else { - self.config.permissions.effective_permission_profile() + ( + self.config.permissions.effective_permission_profile(), + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + ) + }; + let started_network_proxy = match network_proxy_spec.as_ref() { + Some(spec) => match spec + .start_proxy( + &network_proxy_permission_profile, + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + { + Ok(started) => Some(started), + Err(err) => { + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); + } + }, + None => None, + }; + let exec_params = ExecParams { + command, + cwd: cwd.clone(), + expiration, + capture_policy, + env, + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level, + windows_sandbox_private_desktop: self + .config + .permissions + .windows_sandbox_private_desktop, + justification: None, + arg0: None, }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -304,16 +335,4 @@ impl CommandExecRequestProcessor { }) .await } - - fn preserve_configured_deny_read_restrictions( - file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, - ) { - file_system_sandbox_policy - .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); - } } - -#[cfg(test)] -#[path = "command_exec_processor_tests.rs"] -mod command_exec_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs b/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs deleted file mode 100644 index 3e026a6a82..0000000000 --- a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_utils_absolute_path::test_support::PathBufExt; -use codex_utils_absolute_path::test_support::test_path_buf; -use pretty_assertions::assert_eq; - -#[test] -fn command_profile_preserves_configured_deny_read_restrictions() { - let readable_entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: test_path_buf("/tmp/project").abs(), - }, - access: FileSystemAccessMode::Read, - }; - let deny_entry = FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/project/**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }; - let mut file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); - let mut configured_file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); - configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); - - CommandExecRequestProcessor::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - - let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); - expected.glob_scan_max_depth = Some(2); - assert_eq!(file_system_sandbox_policy, expected); -} diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 2a4b8435ef..e4c573e72f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -5,6 +5,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::ActivePermissionProfile; use codex_app_server_protocol::CommandExecOutputDeltaNotification; use codex_app_server_protocol::CommandExecOutputStream; use codex_app_server_protocol::CommandExecParams; @@ -13,19 +14,13 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::PermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::path::Path; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; @@ -224,7 +219,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { env: None, size: None, sandbox_policy: None, - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(ActivePermissionProfile::read_only()), }) .await?; @@ -244,6 +239,105 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { Ok(()) } +#[tokio::test] +async fn command_exec_permission_profile_starts_selected_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config( + codex_home.path(), + /*default_permissions*/ None, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(ActivePermissionProfile::new("networked")), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "1".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(ActivePermissionProfile::read_only()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "unset".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { @@ -252,23 +346,17 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu let command_dir = codex_home.path().join("command-cwd"); std::fs::create_dir(&command_dir)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_command_exec_config( + codex_home.path(), + r#" +[permissions.command-cwd.filesystem] +":root" = "read" +":workspace_roots" = "write" +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let mut permission_profile = root_read_only_permission_profile(); - let PermissionProfile::Managed { file_system, .. } = &mut permission_profile else { - panic!("root read-only helper should use managed permissions"); - }; - let PermissionProfileFileSystemPermissions::Restricted { entries, .. } = file_system else { - panic!("root read-only helper should use restricted filesystem permissions"); - }; - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { subpath: None }, - }, - access: FileSystemAccessMode::Write, - }); - let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec![ @@ -288,7 +376,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile: Some(ActivePermissionProfile::new("command-cwd")), }) .await?; @@ -335,7 +423,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result env: None, size: None, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(ActivePermissionProfile::read_only()), }) .await?; @@ -1061,19 +1149,41 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } -fn root_read_only_permission_profile() -> PermissionProfile { - PermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, - } +fn insert_networked_permission_profile_config( + codex_home: &Path, + default_permissions: Option<&str>, +) -> Result<()> { + let default_permissions = default_permissions + .map(|default_permissions| format!("default_permissions = \"{default_permissions}\"\n\n")) + .unwrap_or_default(); + let inserted_config = format!( + r#"{default_permissions}[features] +network_proxy = true + +[permissions.networked.filesystem] +":root" = "read" + +[permissions.networked.network] +enabled = true +proxy_url = "http://127.0.0.1:0" +enable_socks5 = false + +"# + ); + insert_command_exec_config(codex_home, &inserted_config)?; + Ok(()) +} + +fn insert_command_exec_config(codex_home: &Path, inserted_config: &str) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + let marker = "\n[model_providers.mock_provider]\n"; + let (prefix, suffix) = config + .split_once(marker) + .context("test config should include mock provider table")?; + let config = format!("{prefix}\n{inserted_config}{marker}{suffix}"); + std::fs::write(config_path, config)?; + Ok(()) } async fn read_initialize_response( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 48a18a5cd9..5335f7c46b 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -353,6 +353,10 @@ impl ActivePermissionProfile { extends: None, } } + + pub fn read_only() -> Self { + Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) + } } impl Default for PermissionProfile { diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index b503b13112..6d94509a4f 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -46,14 +46,13 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; @@ -80,10 +79,9 @@ mod tests { #[test] fn returns_none_for_external_sandbox() { - let profile: PermissionProfile = AppServerPermissionProfile::External { - network: PermissionProfileNetworkPermissions { enabled: true }, - } - .into(); + let profile: PermissionProfile = PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, + }; let dirs = vec![PathBuf::from("/tmp/example")]; assert_eq!( add_dir_warning_message(&dirs, &profile, Path::new("/tmp/project")), @@ -105,9 +103,9 @@ mod tests { #[test] fn warns_when_profile_can_write_elsewhere_but_not_cwd() { - let profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -124,8 +122,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; let dirs = vec![PathBuf::from("/tmp/extra")]; assert_eq!( diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index eb56325663..a531617e30 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -123,16 +123,15 @@ mod tests { use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_config::types::ApprovalsReviewer; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -255,9 +254,9 @@ mod tests { let mut app = make_test_app().await; let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000403").expect("valid thread"); - let profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -274,8 +273,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; let session = ThreadSessionState { permission_profile: profile.clone(), ..test_thread_session(thread_id, test_path_buf("/tmp/main")) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index db6dc9e9a8..2620f80d53 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1604,13 +1604,6 @@ mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -1621,7 +1614,13 @@ mod tests { use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -1843,9 +1842,9 @@ mod tests { fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1860,8 +1859,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1872,9 +1870,9 @@ mod tests { #[test] fn sandbox_mode_projects_cwd_write_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1891,8 +1889,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 381c5430a9..15f5431dad 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1,11 +1,10 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::collections::VecDeque; @@ -99,9 +98,9 @@ async fn submission_includes_configured_active_permission_profile() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let expected_permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -118,8 +117,7 @@ async fn submission_includes_configured_active_permission_profile() { ], glob_scan_max_depth: None, }, - } - .into(); + }; let expected_active_permission_profile = ActivePermissionProfile::new("custom"); let configured = crate::session_state::ThreadSessionState { thread_id, @@ -170,11 +168,10 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - } - .into(); + let expected_permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Unrestricted, + }; let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 7dca128448..24e408b019 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1,13 +1,12 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::NetworkAccess; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::SandboxPolicy; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; #[tokio::test] @@ -232,9 +231,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config.cwd = test_path_buf("/home/user/main").abs(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); - let expected_app_server_permission_profile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let expected_app_server_permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -252,8 +251,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { glob_scan_max_depth: None, }, }; - let expected_permission_profile: PermissionProfile = - expected_app_server_permission_profile.clone().into(); + let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_core_sandbox = expected_permission_profile .to_legacy_sandbox_policy(expected_cwd.as_path()) .expect("permission profile should project to legacy sandbox policy"); @@ -288,9 +286,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from( - chat.config_ref().permissions.effective_permission_profile() - ), + chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); @@ -370,11 +366,10 @@ async fn session_configured_preserves_profile_workspace_roots() { async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let expected_app_server_permission_profile = AppServerPermissionProfile::External { - network: PermissionProfileNetworkPermissions { enabled: false }, + let expected_app_server_permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; - let expected_permission_profile: PermissionProfile = - expected_app_server_permission_profile.clone().into(); + let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_sandbox = SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, }; @@ -404,9 +399,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from( - chat.config_ref().permissions.effective_permission_profile() - ), + chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile ); } diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 9928676e4f..d682d24d66 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,17 +1,16 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> PermissionProfile { - AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -45,7 +44,6 @@ fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> Permission glob_scan_max_depth: None, }, } - .into() } #[tokio::test] @@ -127,9 +125,9 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() .into_iter() .find(|p| p.id == "read-only") .expect("read-only preset exists"); - let current_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let current_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -146,8 +144,7 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() ], glob_scan_max_depth: None, }, - } - .into(); + }; let cwd = test_path_buf("/tmp/project").abs(); assert!( diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 045edf99d1..797037a33b 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -51,9 +51,6 @@ use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::McpAuthStatus; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::ToolRequestUserInputAnswer; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_app_server_protocol::WebSearchAction; @@ -68,9 +65,11 @@ use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::mcp::Resource; #[cfg(test)] use codex_protocol::mcp::ResourceTemplate; +use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::local_image_label_text; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 3c667f4dca..46bbb35eed 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -227,14 +227,13 @@ pub(crate) fn has_yolo_permissions( approval_policy: AskForApproval, permission_profile: &PermissionProfile, ) -> bool { - let permission_profile = AppServerPermissionProfile::from(permission_profile.clone()); approval_policy == AskForApproval::Never && matches!( permission_profile, - AppServerPermissionProfile::Disabled - | AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - network: PermissionProfileNetworkPermissions { enabled: true }, + PermissionProfile::Disabled + | PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, } ) } diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 8ea7af5d32..b0b87cdd00 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1470,11 +1470,10 @@ fn session_header_indicates_yolo_mode() { #[test] fn yolo_mode_includes_managed_full_access_profiles() { - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: true }, - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - } - .into(); + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Enabled, + file_system: ManagedFileSystemPermissions::Unrestricted, + }; assert!(has_yolo_permissions( AskForApproval::Never, @@ -1484,10 +1483,9 @@ fn yolo_mode_includes_managed_full_access_profiles() { #[test] fn yolo_mode_excludes_external_sandbox_profiles() { - let permission_profile: PermissionProfile = AppServerPermissionProfile::External { - network: PermissionProfileNetworkPermissions { enabled: true }, - } - .into(); + let permission_profile: PermissionProfile = PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, + }; assert!(!has_yolo_permissions( AskForApproval::Never, diff --git a/codex-rs/tui/src/permission_compat.rs b/codex-rs/tui/src/permission_compat.rs index 93dc40e19f..953793feba 100644 --- a/codex-rs/tui/src/permission_compat.rs +++ b/codex-rs/tui/src/permission_compat.rs @@ -44,22 +44,21 @@ pub(crate) fn legacy_compatible_permission_profile( #[cfg(test)] mod tests { use super::*; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; + use codex_protocol::models::ManagedFileSystemPermissions; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; #[test] fn compatibility_profile_preserves_unbridgeable_write_roots() { let cwd = AbsolutePathBuf::try_from("/workspace/project").expect("absolute cwd"); let extra_root = AbsolutePathBuf::try_from("/workspace/extra").expect("absolute root"); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -76,8 +75,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; let compatibility_profile = legacy_compatible_permission_profile(&permission_profile, cwd.as_path()); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 9380718196..019aebda73 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -15,13 +15,6 @@ use chrono::TimeZone; use chrono::Utc; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CreditsSnapshot; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_config::LoaderOverrides; @@ -31,10 +24,14 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use insta::assert_snapshot; @@ -43,11 +40,13 @@ use ratatui::prelude::*; use tempfile::TempDir; fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile { - AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { - enabled: network_enabled, + PermissionProfile::Managed { + network: if network_enabled { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -77,7 +76,6 @@ fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfil glob_scan_max_depth: None, }, } - .into() } async fn test_config(temp_home: &TempDir) -> Config { @@ -308,9 +306,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { .permissions .set_permission_profile_from_session_snapshot( PermissionProfile::read_only(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + Some(ActivePermissionProfile::read_only()), ) .expect("set permission profile"); @@ -340,9 +336,7 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() &file_system_policy, NetworkSandboxPolicy::Restricted, ), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + Some(ActivePermissionProfile::read_only()), ) .expect("set permission profile"); @@ -703,13 +697,10 @@ async fn status_permissions_full_disk_managed_with_network_is_danger_full_access .expect("set approval policy"); config .permissions - .set_permission_profile( - AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: true }, - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - } - .into(), - ) + .set_permission_profile(PermissionProfile::Managed { + network: NetworkSandboxPolicy::Enabled, + file_system: ManagedFileSystemPermissions::Unrestricted, + }) .expect("set permission profile"); assert_eq!( @@ -729,13 +720,10 @@ async fn status_permissions_full_disk_managed_without_network_is_external_sandbo .expect("set approval policy"); config .permissions - .set_permission_profile( - AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - } - .into(), - ) + .set_permission_profile(PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Unrestricted, + }) .expect("set permission profile"); assert_eq!( From 326e31ab65dcbdf70c4a034b7adc5c8bd335d996 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 16 May 2026 05:55:05 +0300 Subject: [PATCH 030/114] [codex] Refine Python SDK user-facing docs (#22941) ## Summary - Remove maintainer and release-process wording from the Python SDK README and docs. - Rewrite SDK-facing comments/docstrings so they read as standalone product documentation. - Add a real app-server integration smoke that follows the public quickstart-style `Codex() -> thread_start() -> run()` path. ## Integration coverage - Add `test_real_quickstart_style_flow_smoke` in the real app-server integration suite. ## Validation - Local tests were not run per repo guidance. CI should validate this branch once the PR is online. --- sdk/python/README.md | 45 ++----------------- sdk/python/docs/faq.md | 23 ---------- sdk/python/docs/getting-started.md | 2 +- sdk/python/src/openai_codex/api.py | 2 +- sdk/python/src/openai_codex/types.py | 2 +- .../tests/test_real_app_server_integration.py | 32 +++++++++++++ 6 files changed, 38 insertions(+), 68 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 31d8213b5f..d3aeff7ac5 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -17,10 +17,8 @@ source .venv/bin/activate ``` Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency -with the same version as the SDK. For local repo development, either pass -`AppServerConfig(codex_bin=...)` to point at a local build explicitly, or use -the repo examples/notebook bootstrap which installs the pinned runtime package -automatically. +with the same version as the SDK. Pass `AppServerConfig(codex_bin=...)` only +when you intentionally want to run against a specific local app-server binary. ## Quickstart @@ -55,49 +53,12 @@ python examples/01_quickstart_constructor/sync.py python examples/01_quickstart_constructor/async.py ``` -## Runtime packaging - -The repo no longer checks `codex` binaries into `sdk/python`. +## Runtime Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version, and that runtime package carries the platform-specific binary for the target wheel. The SDK package version and runtime package version must match. -For local repo development, the checked-in `sdk/python-runtime` package is only -a template for staged release artifacts. Editable installs should use an -explicit `codex_bin` override for manual SDK usage; the repo examples and -notebook bootstrap the pinned runtime package automatically. - -## Maintainer workflow - -```bash -cd sdk/python -uv sync -python scripts/update_sdk_artifacts.py generate-types -python scripts/update_sdk_artifacts.py \ - stage-sdk \ - /tmp/codex-python-release/openai-codex \ - --codex-version -python scripts/update_sdk_artifacts.py \ - stage-runtime \ - /tmp/codex-python-release/openai-codex-cli-bin \ - /path/to/codex \ - --codex-version -``` - -Pass `--platform-tag ...` to `stage-runtime` when the wheel should be tagged for -a Rust target that differs from the Python build host. The intended one-off -matrix is `macosx_11_0_arm64`, `macosx_10_9_x86_64`, -`musllinux_1_1_aarch64`, `musllinux_1_1_x86_64`, `win_arm64`, and -`win_amd64`. - -This supports the CI release flow: - -- run `generate-types` before packaging -- stage `openai-codex` once with an exact `openai-codex-cli-bin==...` dependency -- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `openai-codex-cli-bin` as platform wheels only through PyPI trusted publishing; do not publish an sdist - ## Compatibility and versioning - Package: `openai-codex` diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 11118d8c9c..1a87b50a14 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -59,29 +59,6 @@ Common causes: - local auth/session is missing - incompatible/old app-server -Maintainers stage releases by building the SDK once and the runtime once per -platform with the same pinned runtime version. Publish `openai-codex-cli-bin` -as platform wheels only; do not publish an sdist: - -```bash -cd sdk/python -python scripts/update_sdk_artifacts.py generate-types -python scripts/update_sdk_artifacts.py \ - stage-sdk \ - /tmp/codex-python-release/openai-codex \ - --codex-version -python scripts/update_sdk_artifacts.py \ - stage-runtime \ - /tmp/codex-python-release/openai-codex-cli-bin \ - /path/to/codex \ - --codex-version -``` - -If you are packaging a binary for a different target than the Python build -host, pass `--platform-tag ...` to `stage-runtime`. The intended one-off matrix -is `macosx_11_0_arm64`, `macosx_10_9_x86_64`, `musllinux_1_1_aarch64`, -`musllinux_1_1_x86_64`, `win_arm64`, and `win_amd64`. - ## Why does a turn "hang"? A turn is complete only when `turn/completed` arrives for that turn ID. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index e1c2e27bf5..6e37314e77 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -2,7 +2,7 @@ This is the fastest path from install to a multi-turn thread using the public SDK surface. -The SDK is experimental. Treat the API, bundled runtime strategy, and packaging details as unstable until the first public release. +The SDK is experimental, so the public API and runtime requirements may keep evolving before the first public release. ## 1) Install diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 3d47de2a0e..c894447124 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -113,7 +113,7 @@ def _approval_mode_override_settings( class Codex: - """Minimal typed SDK surface for app-server v2.""" + """Typed Python client for app-server v2 workflows.""" def __init__(self, config: AppServerConfig | None = None) -> None: self._client = AppServerClient(config=config) diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index bda240afeb..995daa68b9 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -1,4 +1,4 @@ -"""Public generated app-server model exports for type annotations and matching.""" +"""Public app-server model exports for type annotations and matching.""" from __future__ import annotations diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 292ba9632a..7fcadcf534 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -295,6 +295,38 @@ def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> N assert isinstance(data["has_usage"], bool) +def test_real_quickstart_style_flow_smoke(runtime_env: PreparedRuntimeEnv) -> None: + data = _run_json_python( + runtime_env, + textwrap.dedent( + """ + import json + from openai_codex import Codex + + with Codex() as codex: + thread = codex.thread_start() + result = thread.run("Say hello in one sentence.") + print(json.dumps({ + "thread_id": thread.id, + "final_response": result.final_response, + "items_count": len(result.items), + })) + """ + ), + ) + + assert { + "thread_id_is_text": isinstance(data["thread_id"], str) and bool(data["thread_id"].strip()), + "final_response_is_text": isinstance(data["final_response"], str) + and bool(data["final_response"].strip()), + "items_count_is_int": isinstance(data["items_count"], int), + } == { + "thread_id_is_text": True, + "final_response_is_text": True, + "items_count_is_int": True, + } + + def test_real_async_thread_turn_usage_and_ids_smoke( runtime_env: PreparedRuntimeEnv, ) -> None: From de9c5c0226f8f31b9ce7dfef8b9ad401126ca3b5 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 00:39:27 -0700 Subject: [PATCH 031/114] Fix Windows doctor npm root probe (#22967) ## Why On Windows npm-managed installs expose the working shim as `npm.cmd`. `codex doctor` probed bare `npm`, which could incorrectly report that npm global-root inspection was unavailable even when the install was healthy. Fixes #22964. ## What changed - Use `npm.cmd` for the doctor npm-root probe on Windows. - Keep the existing `npm` probe on non-Windows platforms. --- codex-rs/cli/src/doctor.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index a3742baa72..f2b1bca6d7 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -105,6 +105,10 @@ const COLOR_ENV_VARS: &[&str] = &[ const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"]; const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"]; const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"]; +#[cfg(windows)] +const NPM_COMMAND: &str = "npm.cmd"; +#[cfg(not(windows))] +const NPM_COMMAND: &str = "npm"; const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ "SSH_TTY", "SSH_CONNECTION", @@ -884,7 +888,7 @@ fn npm_global_root_check() -> NpmRootCheck { return NpmRootCheck::MissingPackageRoot; }; - let output = match run_command("npm", ["root", "-g"]) { + let output = match run_command(NPM_COMMAND, ["root", "-g"]) { Ok(output) => output, Err(err) => return NpmRootCheck::NpmUnavailable(err), }; From 108234b5ebe6941764a6b8edbb37b2aa04369f07 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 16 May 2026 07:26:18 -0700 Subject: [PATCH 032/114] core: set permission profiles from snapshots (#22920) ## Why #22891 moved the TUI turn-command path to pass `ActivePermissionProfile` instead of the full `PermissionProfile`, but the remaining config/session bridge still accepted the concrete `PermissionProfile` and active profile id as separate arguments. That shape made it too easy for future callers to update the concrete profile and active profile id out of sync. This PR makes the trusted session snapshot path pass one coherent value into `Permissions`, while keeping `requirements.toml` enforcement owned by the existing constrained permission state. ## What Changed - Added `PermissionProfileSnapshot` as the public snapshot value for trusted session/config synchronization. - Changed `Permissions::set_permission_profile_from_session_snapshot()` and `replace_permission_profile_from_session_snapshot()` to take a `PermissionProfileSnapshot`. - Updated the replacement path to derive its constrained `PermissionProfile` from the snapshot, so callers cannot pass a separate profile that disagrees with the snapshot. - Removed the internal tuple-style `PermissionProfileState::set_active_permission_profile()` mutation path. - Updated core session projection and TUI call sites to construct explicit legacy or active snapshots. - Documented the snapshot constructors so legacy use and id/profile mismatch hazards are called out at the API boundary. - Added a focused config test that verifies snapshot updates still respect existing permission constraints. ## How To Review 1. Start with `codex-rs/core/src/config/resolved_permission_profile.rs`; `PermissionProfileSnapshot` is the public wrapper, while `ResolvedPermissionProfile` stays internal. 2. Check `codex-rs/core/src/config/mod.rs` to confirm both session-snapshot setters validate through `PermissionProfileState` and no longer accept loose profile/id pairs. 3. Skim `codex-rs/core/src/session/session.rs` for the session projection path; it now builds the snapshot before installing it. 4. Skim the TUI changes as call-site migration from loose argument pairs to explicit snapshot construction. ## Verification - `cargo test -p codex-core permission_snapshot_setter_preserves_permission_constraints` - `cargo test -p codex-tui status_permissions_` - `cargo test -p codex-tui session_configured_preserves_profile_workspace_roots` - `just fix -p codex-core -p codex-tui` --- codex-rs/core/src/config/config_tests.rs | 24 ++++ codex-rs/core/src/config/mod.rs | 48 ++------ .../src/config/resolved_permission_profile.rs | 114 ++++++++++++++++-- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 19 ++- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/config_persistence.rs | 12 +- codex-rs/tui/src/app/event_dispatch.rs | 6 +- codex-rs/tui/src/app/tests.rs | 11 +- codex-rs/tui/src/app/thread_session_state.rs | 7 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/session_flow.rs | 14 +-- codex-rs/tui/src/chatwidget/settings.rs | 5 +- .../src/chatwidget/tests/history_replay.rs | 5 +- codex-rs/tui/src/status/tests.rs | 89 +++++++------- 15 files changed, 225 insertions(+), 132 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7a4a3a5c7b..b8bd7d2398 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1453,6 +1453,30 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: Ok(()) } +#[test] +fn permission_snapshot_setter_preserves_permission_constraints() { + let initial_profile = PermissionProfile::read_only(); + let mut permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_only(initial_profile.clone()), + ) + .expect("initial permissions should satisfy constraints"); + + let err = permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect_err("workspace profile should violate read-only constraint"); + + assert_eq!(permissions.permission_profile(), &initial_profile); + assert_eq!(permissions.active_permission_profile(), None); + assert!( + matches!(err, ConstraintError::InvalidValue { .. }), + "expected invalid value constraint error, got {err:?}" + ); +} + #[tokio::test] async fn permission_profile_override_preserves_managed_unrestricted_filesystem() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bad23da221..18158671ec 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -149,6 +149,7 @@ pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub(crate) use permissions::resolve_permission_profile; +pub use resolved_permission_profile::PermissionProfileSnapshot; pub(crate) use resolved_permission_profile::PermissionProfileState; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; @@ -311,30 +312,13 @@ impl Permissions { /// /// This is a trusted-state bridge for consumers of `SessionConfigured`. /// Config loading and app-server selection should resolve named profiles - /// through config instead of constructing this pair directly. + /// through config instead of constructing a snapshot directly. pub fn set_permission_profile_from_session_snapshot( &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, + snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { - self.set_permission_profile_from_session_snapshot_with_profile_workspace_roots( - permission_profile, - active_permission_profile, - Vec::new(), - ) - } - - pub fn set_permission_profile_from_session_snapshot_with_profile_workspace_roots( - &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, - profile_workspace_roots: Vec, - ) -> ConstraintResult<()> { - self.permission_profile_state.set_active_permission_profile( - permission_profile, - active_permission_profile, - profile_workspace_roots, - ) + self.permission_profile_state + .set_permission_profile_snapshot(snapshot) } /// Replace the current permission constraints with a trusted session @@ -342,26 +326,12 @@ impl Permissions { /// after their local config constraints reject the snapshot. pub fn replace_permission_profile_from_session_snapshot( &mut self, - permission_profile: Constrained, - active_permission_profile: Option, + snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { - self.replace_permission_profile_from_session_snapshot_with_profile_workspace_roots( + let permission_profile = Constrained::allow_only(snapshot.permission_profile().clone()); + self.permission_profile_state = PermissionProfileState::from_constrained_resolved( permission_profile, - active_permission_profile, - Vec::new(), - ) - } - - pub fn replace_permission_profile_from_session_snapshot_with_profile_workspace_roots( - &mut self, - permission_profile: Constrained, - active_permission_profile: Option, - profile_workspace_roots: Vec, - ) -> ConstraintResult<()> { - self.permission_profile_state = PermissionProfileState::from_constrained_active_profile( - permission_profile, - active_permission_profile, - profile_workspace_roots, + snapshot.into_resolved_permission_profile(), )?; Ok(()) } diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs index 911d9bf2e8..f0fff66bcc 100644 --- a/codex-rs/core/src/config/resolved_permission_profile.rs +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -40,6 +40,19 @@ pub(crate) enum ResolvedPermissionProfile { Named(NamedPermissionProfile), } +/// Trusted snapshot of a resolved permission profile. +/// +/// This is a bridge for already-resolved session/config state. It keeps the +/// concrete `PermissionProfile`, optional active profile id, and +/// profile-defined workspace roots together so `Permissions` can validate and +/// install them atomically. It is not a resolver: callers that are handling +/// user-selected profile ids should resolve those ids through config instead +/// of constructing this type directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSnapshot { + resolved_permission_profile: ResolvedPermissionProfile, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LegacyPermissionProfile { permission_profile: PermissionProfile, @@ -124,6 +137,93 @@ impl ResolvedPermissionProfile { } } +impl PermissionProfileSnapshot { + /// Create a snapshot with no active profile id. + /// + /// Prefer this only for legacy data or local overrides that genuinely do + /// not have a named/built-in profile identity. Using this for a built-in or + /// named profile will intentionally clear the active profile metadata. + pub fn legacy(permission_profile: PermissionProfile) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile), + } + } + + /// Create a snapshot for a known active profile id. + /// + /// Use this only after a trusted caller has already resolved the active id + /// to the supplied concrete `PermissionProfile`. This constructor does not + /// verify that the id and profile match; `Permissions` will still enforce + /// configured permission constraints when the snapshot is installed. + pub fn active( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + ) -> Self { + Self::active_with_profile_workspace_roots( + permission_profile, + active_permission_profile, + Vec::new(), + ) + } + + /// Create a snapshot for a known active profile id with profile roots. + /// + /// As with `active`, the caller is responsible for passing the concrete + /// profile and active id that were resolved together. Use this variant when + /// the selected profile declared workspace roots that should remain + /// distinct from turn-scoped runtime workspace roots. + pub fn active_with_profile_workspace_roots( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + profile_workspace_roots: Vec, + ) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::from_active_profile( + permission_profile, + Some(active_permission_profile), + profile_workspace_roots, + ), + } + } + + /// Reconstruct a trusted snapshot from session state. + /// + /// This is intended for session responses emitted by core, where the + /// concrete profile and active profile id were captured together. Avoid + /// using this as a shortcut for arbitrary user input because mismatched + /// arguments can still misrepresent the active profile identity. + pub fn from_session_snapshot( + permission_profile: PermissionProfile, + active_permission_profile: Option, + ) -> Self { + match active_permission_profile { + Some(active_permission_profile) => { + Self::active(permission_profile, active_permission_profile) + } + None => Self::legacy(permission_profile), + } + } + + /// Borrow the concrete permission profile captured in this snapshot. + pub fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.permission_profile() + } + + /// Return the active profile id captured in this snapshot, if any. + pub fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile.active_permission_profile() + } + + /// Borrow profile-declared workspace roots captured in this snapshot. + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile.profile_workspace_roots() + } + + pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile { + self.resolved_permission_profile + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct PermissionProfileState { resolved_permission_profile: Constrained, @@ -199,17 +299,11 @@ impl PermissionProfileState { .set(ResolvedPermissionProfile::legacy(permission_profile)) } - pub(crate) fn set_active_permission_profile( + pub(crate) fn set_permission_profile_snapshot( &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, - profile_workspace_roots: Vec, + snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { - let candidate = ResolvedPermissionProfile::from_active_profile( - permission_profile, - active_permission_profile, - profile_workspace_roots, - ); - self.resolved_permission_profile.set(candidate) + self.resolved_permission_profile + .set(snapshot.into_resolved_permission_profile()) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 2dd97710e8..d67e79f794 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -173,6 +173,7 @@ use crate::compact::collect_user_messages; use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; +use crate::config::PermissionProfileSnapshot; use crate::config::PermissionProfileState; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b57e11895..3f8276d04d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -357,11 +357,20 @@ impl SessionConfiguration { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile_state.set_active_permission_profile( - effective_permission_profile, - active_permission_profile, - profile_workspace_roots, - ) + + let permission_snapshot = match active_permission_profile { + Some(active_permission_profile) => { + PermissionProfileSnapshot::active_with_profile_workspace_roots( + effective_permission_profile, + active_permission_profile, + profile_workspace_roots, + ) + } + None => PermissionProfileSnapshot::legacy(effective_permission_profile), + }; + + self.permission_profile_state + .set_permission_profile_snapshot(permission_snapshot) } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 00e45f979b..46a7be00dd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -47,6 +47,7 @@ use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::PermissionProfileSnapshot; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 4a410f8130..6f4fda608e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -128,10 +128,10 @@ impl App { if let Err(err) = config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( permission_profile.clone(), - Some(active_permission_profile), - ) + active_permission_profile, + )) { tracing::warn!(error = %err, "{log_message}"); self.chat_widget @@ -328,8 +328,10 @@ impl App { && let Err(err) = self .chat_widget .set_permission_profile_from_session_snapshot( - permission_profile.clone(), - active_permission_profile_override.clone(), + PermissionProfileSnapshot::from_session_snapshot( + permission_profile.clone(), + active_permission_profile_override.clone(), + ), ) { tracing::error!( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e25e4d16..15e4f9b6cd 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1422,8 +1422,10 @@ impl App { if let Err(err) = self .chat_widget .set_permission_profile_from_session_snapshot( - permission_profile_for_chat, - Some(active_permission_profile), + PermissionProfileSnapshot::active( + permission_profile_for_chat, + active_permission_profile, + ), ) { tracing::warn!(%err, "failed to set permission profile on chat config"); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a925b4d488..c42f42c257 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -28,6 +28,7 @@ use crate::app_command::AppCommand as Op; use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::PermissionProfileSnapshot; use crate::legacy_core::config::TerminalResizeReflowMaxRows; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -1730,10 +1731,9 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( PermissionProfile::workspace_write(), - /*active_profile*/ None, - )?; + ))?; app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) .await; @@ -3153,10 +3153,9 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( parent_permission_profile.clone(), - /*active_profile*/ None, - ) + )) .expect("test permission profile should be accepted"); app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index a531617e30..4037a085eb 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -120,6 +120,7 @@ mod tests { use crate::app::side::SideThreadState; use crate::app::test_support::make_test_app; use crate::app::thread_events::ThreadEventChannel; + use crate::legacy_core::config::PermissionProfileSnapshot; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; @@ -201,10 +202,10 @@ mod tests { ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); app.chat_widget.handle_thread_session(main_session.clone()); app.chat_widget - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( expected_permission_profile.clone(), - Some(expected_active_permission_profile.clone()), - ) + expected_active_permission_profile.clone(), + )) .expect("set widget permission profile"); app.config .permissions diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 207c547562..a37371bf73 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -61,6 +61,7 @@ use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; use crate::legacy_core::config::ConstraintResult; +use crate::legacy_core::config::PermissionProfileSnapshot; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::mention_codec::LinkedMention; diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 5dfb2090f7..2b8beefdf8 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -49,22 +49,20 @@ impl ChatWidget { self.config.permissions.approval_policy = Constrained::allow_only(session.approval_policy.to_core()); } + let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( + session.permission_profile.clone(), + session.active_permission_profile.clone(), + ); let permission_sync = self .config .permissions - .set_permission_profile_from_session_snapshot( - session.permission_profile.clone(), - session.active_permission_profile.clone(), - ); + .set_permission_profile_from_session_snapshot(permission_snapshot.clone()); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); if let Err(replace_err) = self .config .permissions - .replace_permission_profile_from_session_snapshot( - Constrained::allow_only(session.permission_profile.clone()), - session.active_permission_profile.clone(), - ) + .replace_permission_profile_from_session_snapshot(permission_snapshot) { tracing::error!( %replace_err, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index a2125c3f47..91abc728db 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -20,12 +20,11 @@ impl ChatWidget { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_permission_profile_from_session_snapshot( &mut self, - profile: PermissionProfile, - active_profile: Option, + snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { self.config .permissions - .set_permission_profile_from_session_snapshot(profile, active_profile)?; + .set_permission_profile_from_session_snapshot(snapshot)?; self.refresh_status_surfaces(); Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 24e408b019..36b3e0189e 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -292,10 +292,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { assert_eq!(&chat.config_ref().cwd, &expected_cwd); let updated_profile = PermissionProfile::workspace_write(); - chat.set_permission_profile_from_session_snapshot( + chat.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( updated_profile.clone(), - /*active_profile*/ None, - ) + )) .expect("set permission profile"); assert_eq!( chat.config_ref().permissions.permission_profile(), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 019aebda73..3a10e0ed94 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -5,6 +5,7 @@ use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::PermissionProfileSnapshot; use crate::status::StatusAccountDisplay; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; @@ -304,10 +305,10 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::read_only(), - Some(ActivePermissionProfile::read_only()), - ) + ActivePermissionProfile::read_only(), + )) .expect("set permission profile"); assert_eq!( @@ -331,13 +332,13 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() .with_additional_writable_roots(config.cwd.as_path(), std::slice::from_ref(&extra_root)); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, ), - Some(ActivePermissionProfile::read_only()), - ) + ActivePermissionProfile::read_only(), + )) .expect("set permission profile"); assert_eq!( @@ -357,12 +358,10 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - )), - ) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) .expect("set permission profile"); assert_eq!( @@ -383,12 +382,10 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - )), - ) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) .expect("set permission profile"); assert_eq!( @@ -409,17 +406,15 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { let extra_root = test_path_buf("/workspace/extra").abs(); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write_with( std::slice::from_ref(&extra_root), NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - )), - ) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) .expect("set permission profile"); assert_eq!( @@ -445,10 +440,10 @@ async fn status_permissions_workspace_roots_show_additional_directories() { .set_workspace_roots(config.workspace_roots.clone()); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write(), - Some(ActivePermissionProfile::new(":workspace")), - ) + ActivePermissionProfile::new(":workspace"), + )) .expect("set permission profile"); assert_eq!( @@ -470,15 +465,17 @@ async fn status_permissions_workspace_roots_include_profile_defined_directories( let profile_root = test_path_buf("/workspace/shared").abs(); config .permissions - .set_permission_profile_from_session_snapshot_with_profile_workspace_roots( - PermissionProfile::workspace_write_with( - std::slice::from_ref(&profile_root), - NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::active_with_profile_workspace_roots( + PermissionProfile::workspace_write_with( + std::slice::from_ref(&profile_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + ActivePermissionProfile::new(":workspace"), + vec![profile_root.clone()], ), - Some(ActivePermissionProfile::new(":workspace")), - vec![profile_root.clone()], ) .expect("set permission profile"); @@ -502,17 +499,15 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Enabled, /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - )), - ) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) .expect("set permission profile"); assert_eq!( @@ -527,10 +522,10 @@ async fn status_permissions_user_defined_profile_shows_name() { let mut config = test_config(&temp_home).await; config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::read_only(), - Some(ActivePermissionProfile::new("locked")), - ) + ActivePermissionProfile::new("locked"), + )) .expect("set permission profile"); assert_eq!( @@ -547,10 +542,10 @@ async fn status_snapshot_shows_active_user_defined_profile() { set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::read_only(), - Some(ActivePermissionProfile::new("locked")), - ) + ActivePermissionProfile::new("locked"), + )) .expect("set permission profile"); let usage = TokenUsage::default(); @@ -645,12 +640,10 @@ async fn status_snapshot_shows_auto_review_permissions() { config.approvals_reviewer = ApprovalsReviewer::AutoReview; config .permissions - .set_permission_profile_from_session_snapshot( + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( PermissionProfile::workspace_write(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - )), - ) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) .expect("set permission profile"); let usage = TokenUsage::default(); From a2802480211a6b28f3c00c0ca9bbb2838503eba4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 16 May 2026 19:47:51 +0300 Subject: [PATCH 033/114] [codex] Split Python SDK helper logic (#22939) ## Summary - Move approval-mode mapping into `sdk/python/src/openai_codex/_approval_mode.py`. - Move initialize metadata parsing and normalization into `sdk/python/src/openai_codex/_initialize_metadata.py`. - Keep the public `ApprovalMode` export stable and retarget direct metadata helper coverage. ## Integration coverage - Add an app-server harness smoke that exercises sync and async SDK initialization plus thread creation. ## Validation - Local tests were not run per repo guidance. CI should validate this branch once the PR is online. --- sdk/python/src/openai_codex/_approval_mode.py | 51 ++++++++ .../src/openai_codex/_initialize_metadata.py | 54 +++++++++ sdk/python/src/openai_codex/api.py | 110 ++---------------- sdk/python/tests/test_app_server_lifecycle.py | 55 +++++++++ .../tests/test_public_api_signatures.py | 5 +- 5 files changed, 173 insertions(+), 102 deletions(-) create mode 100644 sdk/python/src/openai_codex/_approval_mode.py create mode 100644 sdk/python/src/openai_codex/_initialize_metadata.py diff --git a/sdk/python/src/openai_codex/_approval_mode.py b/sdk/python/src/openai_codex/_approval_mode.py new file mode 100644 index 0000000000..bbb57030c0 --- /dev/null +++ b/sdk/python/src/openai_codex/_approval_mode.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from enum import Enum +from typing import NoReturn + +from .generated.v2_all import ( + ApprovalsReviewer, + AskForApproval, + AskForApprovalValue, +) + + +class ApprovalMode(str, Enum): + """High-level approval behavior for escalated permission requests.""" + + deny_all = "deny_all" + auto_review = "auto_review" + + +def _approval_mode_settings( + approval_mode: ApprovalMode, +) -> tuple[AskForApproval, ApprovalsReviewer | None]: + """Map the public approval mode to generated app-server start params.""" + if not isinstance(approval_mode, ApprovalMode): + supported = ", ".join(mode.value for mode in ApprovalMode) + raise ValueError(f"approval_mode must be one of: {supported}") + + match approval_mode: + case ApprovalMode.auto_review: + return ( + AskForApproval(root=AskForApprovalValue.on_request), + ApprovalsReviewer.auto_review, + ) + case ApprovalMode.deny_all: + return AskForApproval(root=AskForApprovalValue.never), None + case _: + return _assert_never_approval_mode(approval_mode) + + +def _assert_never_approval_mode(approval_mode: NoReturn) -> NoReturn: + """Make approval mode mapping exhaustive for static type checkers.""" + raise AssertionError(f"Unhandled approval mode: {approval_mode!r}") + + +def _approval_mode_override_settings( + approval_mode: ApprovalMode | None, +) -> tuple[AskForApproval | None, ApprovalsReviewer | None]: + """Map an optional public approval mode to app-server override params.""" + if approval_mode is None: + return None, None + return _approval_mode_settings(approval_mode) diff --git a/sdk/python/src/openai_codex/_initialize_metadata.py b/sdk/python/src/openai_codex/_initialize_metadata.py new file mode 100644 index 0000000000..48e5922c46 --- /dev/null +++ b/sdk/python/src/openai_codex/_initialize_metadata.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from .models import InitializeResponse, ServerInfo + + +def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]: + raw = user_agent.strip() + if not raw: + return None, None + if "/" in raw: + name, version = raw.split("/", 1) + return (name or None), (version or None) + parts = raw.split(maxsplit=1) + if len(parts) == 2: + return parts[0], parts[1] + return raw, None + + +def validate_initialize_metadata(payload: InitializeResponse) -> InitializeResponse: + user_agent = (payload.userAgent or "").strip() + server = payload.serverInfo + + server_name: str | None = None + server_version: str | None = None + + if server is not None: + server_name = (server.name or "").strip() or None + server_version = (server.version or "").strip() or None + + if (server_name is None or server_version is None) and user_agent: + parsed_name, parsed_version = _split_user_agent(user_agent) + if server_name is None: + server_name = parsed_name + if server_version is None: + server_version = parsed_version + + normalized_server_name = (server_name or "").strip() + normalized_server_version = (server_version or "").strip() + if not user_agent or not normalized_server_name or not normalized_server_version: + raise RuntimeError( + "initialize response missing required metadata " + f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})" + ) + + if server is None: + payload.serverInfo = ServerInfo( + name=normalized_server_name, + version=normalized_server_version, + ) + else: + server.name = normalized_server_name + server.version = normalized_server_version + + return payload diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index c894447124..0ea99f9b3d 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -2,9 +2,14 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from enum import Enum -from typing import AsyncIterator, Iterator, NoReturn +from typing import AsyncIterator, Iterator +from ._approval_mode import ( + ApprovalMode as ApprovalMode, + _approval_mode_override_settings, + _approval_mode_settings, +) +from ._initialize_metadata import validate_initialize_metadata from ._inputs import ( ImageInput as ImageInput, Input, @@ -25,9 +30,6 @@ from ._run import ( from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( - ApprovalsReviewer, - AskForApproval, - AskForApprovalValue, ModelListResponse, Personality, ReasoningEffort, @@ -55,61 +57,7 @@ from .generated.v2_all import ( TurnStartParams, TurnSteerResponse, ) -from .models import InitializeResponse, JsonObject, Notification, ServerInfo - - -def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]: - raw = user_agent.strip() - if not raw: - return None, None - if "/" in raw: - name, version = raw.split("/", 1) - return (name or None), (version or None) - parts = raw.split(maxsplit=1) - if len(parts) == 2: - return parts[0], parts[1] - return raw, None - - -class ApprovalMode(str, Enum): - """High-level approval behavior for escalated permission requests.""" - - deny_all = "deny_all" - auto_review = "auto_review" - - -def _approval_mode_settings( - approval_mode: ApprovalMode, -) -> tuple[AskForApproval, ApprovalsReviewer | None]: - """Map the public approval mode to generated app-server start params.""" - if not isinstance(approval_mode, ApprovalMode): - supported = ", ".join(mode.value for mode in ApprovalMode) - raise ValueError(f"approval_mode must be one of: {supported}") - - match approval_mode: - case ApprovalMode.auto_review: - return ( - AskForApproval(root=AskForApprovalValue.on_request), - ApprovalsReviewer.auto_review, - ) - case ApprovalMode.deny_all: - return AskForApproval(root=AskForApprovalValue.never), None - case _: - return _assert_never_approval_mode(approval_mode) - - -def _assert_never_approval_mode(approval_mode: NoReturn) -> NoReturn: - """Make approval mode mapping exhaustive for static type checkers.""" - raise AssertionError(f"Unhandled approval mode: {approval_mode!r}") - - -def _approval_mode_override_settings( - approval_mode: ApprovalMode | None, -) -> tuple[AskForApproval | None, ApprovalsReviewer | None]: - """Map an optional public approval mode to app-server override params.""" - if approval_mode is None: - return None, None - return _approval_mode_settings(approval_mode) +from .models import InitializeResponse, JsonObject, Notification class Codex: @@ -119,7 +67,7 @@ class Codex: self._client = AppServerClient(config=config) try: self._client.start() - self._init = self._validate_initialize(self._client.initialize()) + self._init = validate_initialize_metadata(self._client.initialize()) except Exception: self._client.close() raise @@ -130,44 +78,6 @@ class Codex: def __exit__(self, _exc_type, _exc, _tb) -> None: self.close() - @staticmethod - def _validate_initialize(payload: InitializeResponse) -> InitializeResponse: - user_agent = (payload.userAgent or "").strip() - server = payload.serverInfo - - server_name: str | None = None - server_version: str | None = None - - if server is not None: - server_name = (server.name or "").strip() or None - server_version = (server.version or "").strip() or None - - if (server_name is None or server_version is None) and user_agent: - parsed_name, parsed_version = _split_user_agent(user_agent) - if server_name is None: - server_name = parsed_name - if server_version is None: - server_version = parsed_version - - normalized_server_name = (server_name or "").strip() - normalized_server_version = (server_version or "").strip() - if not user_agent or not normalized_server_name or not normalized_server_version: - raise RuntimeError( - "initialize response missing required metadata " - f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})" - ) - - if server is None: - payload.serverInfo = ServerInfo( - name=normalized_server_name, - version=normalized_server_version, - ) - else: - server.name = normalized_server_name - server.version = normalized_server_version - - return payload - @property def metadata(self) -> InitializeResponse: return self._init @@ -354,7 +264,7 @@ class AsyncCodex: try: await self._client.start() payload = await self._client.initialize() - self._init = Codex._validate_initialize(payload) + self._init = validate_initialize_metadata(payload) self._initialized = True except Exception: await self._client.close() diff --git a/sdk/python/tests/test_app_server_lifecycle.py b/sdk/python/tests/test_app_server_lifecycle.py index 0baddccc57..77ba269450 100644 --- a/sdk/python/tests/test_app_server_lifecycle.py +++ b/sdk/python/tests/test_app_server_lifecycle.py @@ -39,6 +39,61 @@ def test_thread_set_name_and_read(tmp_path) -> None: } +def test_sync_and_async_initialization_round_trip_metadata(tmp_path) -> None: + """Public clients should initialize and start threads through app-server.""" + + async def async_scenario(harness: AppServerHarness) -> dict[str, object]: + async with AsyncCodex(config=harness.app_server_config()) as codex: + thread = await codex.thread_start() + server = codex.metadata.serverInfo + return { + "thread_id": thread.id, + "user_agent": codex.metadata.userAgent, + "server_name": None if server is None else server.name, + "server_version": None if server is None else server.version, + } + + with AppServerHarness(tmp_path) as harness: + with Codex(config=harness.app_server_config()) as codex: + thread = codex.thread_start() + server = codex.metadata.serverInfo + sync_summary = { + "thread_id": thread.id, + "user_agent": codex.metadata.userAgent, + "server_name": None if server is None else server.name, + "server_version": None if server is None else server.version, + } + async_summary = asyncio.run(async_scenario(harness)) + + assert { + "sync": { + "thread_id_present": bool(sync_summary["thread_id"]), + "user_agent_present": bool(sync_summary["user_agent"]), + "server_name_present": bool(sync_summary["server_name"]), + "server_version_present": bool(sync_summary["server_version"]), + }, + "async": { + "thread_id_present": bool(async_summary["thread_id"]), + "user_agent_present": bool(async_summary["user_agent"]), + "server_name_present": bool(async_summary["server_name"]), + "server_version_present": bool(async_summary["server_version"]), + }, + } == { + "sync": { + "thread_id_present": True, + "user_agent_present": True, + "server_name_present": True, + "server_version_present": True, + }, + "async": { + "thread_id_present": True, + "user_agent_present": True, + "server_name_present": True, + "server_version_present": True, + }, + } + + def test_thread_list_filters_archived_threads(tmp_path) -> None: """Thread listing should reflect archive state through app-server.""" with AppServerHarness(tmp_path) as harness: diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index f38c2b8c94..f13fb35c08 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -18,6 +18,7 @@ from openai_codex import ( RunResult, Thread, ) +from openai_codex._initialize_metadata import validate_initialize_metadata from openai_codex.types import InitializeResponse EXPECTED_ROOT_EXPORTS = [ @@ -444,7 +445,7 @@ def test_lifecycle_methods_are_codex_scoped() -> None: def test_initialize_metadata_parses_user_agent_shape() -> None: """Initialize metadata should accept the legacy user-agent-only payload shape.""" payload = InitializeResponse.model_validate({"userAgent": "codex-cli/1.2.3"}) - parsed = Codex._validate_initialize(payload) + parsed = validate_initialize_metadata(payload) assert parsed is payload assert parsed.userAgent == "codex-cli/1.2.3" assert parsed.serverInfo is not None @@ -455,7 +456,7 @@ def test_initialize_metadata_parses_user_agent_shape() -> None: def test_initialize_metadata_requires_non_empty_information() -> None: """Initialize metadata should fail when the runtime gives no identity signal.""" try: - Codex._validate_initialize(InitializeResponse.model_validate({})) + validate_initialize_metadata(InitializeResponse.model_validate({})) except RuntimeError as exc: assert "missing required metadata" in str(exc) else: From 941e7f825eb42f32d19ccc30ea137dbd81bb3c48 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 11:49:40 -0700 Subject: [PATCH 034/114] Improve goal completion usage reporting (#22907) ## Why Goal completion follow-up turns currently receive a preformatted English usage sentence such as `time used: 2586 seconds`. That nudges the model to echo an awkward raw seconds count in the final reply, even though the tool result already exposes structured usage fields like `goal.timeUsedSeconds`, `goal.tokensUsed`, and `goal.tokenBudget`. ## What changed - Replace the preformatted completion usage sentence with guidance to read the structured goal fields from the tool result. - Preserve token-budget reporting while allowing the model to phrase elapsed time in a concise, human-friendly way that fits the response language. - Update core coverage for both the generated completion guidance and the session flow that forwards it back to the model. ## Verification Previously, it would have output a final message indicating that it "worked for 303 seconds". Now it shows the following: image --- codex-rs/core/src/session/tests.rs | 2 +- codex-rs/core/src/tools/handlers/goal.rs | 19 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index cad702bca7..18277f468a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8893,7 +8893,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a assert_eq!(complete_output["remainingTokens"], 0); assert_eq!( complete_output["completionBudgetReport"], - "Goal achieved. Report final budget usage to the user: tokens used: 580 of 500." + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." ); let requests = responses.requests(); let completion_followup_request = requests diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index 28e33f2be4..e50095e524 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -87,20 +87,13 @@ fn goal_response( } fn completion_budget_report(goal: &ThreadGoal) -> Option { - let mut parts = Vec::new(); - if let Some(budget) = goal.token_budget { - parts.push(format!("tokens used: {} of {budget}", goal.tokens_used)); - } - if goal.time_used_seconds > 0 { - parts.push(format!("time used: {} seconds", goal.time_used_seconds)); - } - if parts.is_empty() { + if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { None } else { - Some(format!( - "Goal achieved. Report final budget usage to the user: {}.", - parts.join("; ") - )) + Some( + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." + .to_string(), + ) } } @@ -131,7 +124,7 @@ mod tests { goal: Some(goal), remaining_tokens: Some(6_750), completion_budget_report: Some( - "Goal achieved. Report final budget usage to the user: tokens used: 3250 of 10000; time used: 75 seconds." + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." .to_string() ), } From d91bc1561856745b1a32b5516d62b3ba699d7720 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 16 May 2026 12:12:37 -0700 Subject: [PATCH 035/114] test: construct permission profiles directly (#23030) ## Why `SandboxPolicy` is now a legacy compatibility shape, but several tests still built a `SandboxPolicy` only to immediately convert it into `PermissionProfile` for APIs that already accept canonical runtime permissions. Those detours make it harder to audit where legacy sandbox policy is still required, because boundary-only usages are mixed together with ordinary test setup. ## What Changed - Updated tests in `codex-core`, `codex-exec`, `codex-analytics`, and `codex-config` to construct `PermissionProfile` values directly when the code under test takes a permission profile. - Changed exec-policy, request-permissions, session, and sandbox test helpers to pass `PermissionProfile` through instead of converting from `SandboxPolicy` internally. - Left `SandboxPolicy` in place where tests are explicitly exercising legacy compatibility or request/response boundaries. ## Test Plan - `cargo test -p codex-analytics -p codex-config` - `cargo test -p codex-core --lib safety::tests` - `cargo test -p codex-core --lib exec_policy::tests::` - `cargo test -p codex-core --lib exec::tests` - `cargo test -p codex-core --lib guardian_review_session_config` - `cargo test -p codex-core --lib tools::network_approval::tests` - `cargo test -p codex-core --lib tools::runtimes::shell::unix_escalation::tests` - `cargo test -p codex-core --lib managed_network` - `cargo test -p codex-core --test all request_permissions::` - `cargo test -p codex-exec sandbox` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23030). * #23036 * __->__ #23030 --- .../analytics/src/analytics_client_tests.rs | 5 +- codex-rs/config/src/config_requirements.rs | 71 ++++------ .../core/src/config/config_loader_tests.rs | 23 +--- codex-rs/core/src/config/config_tests.rs | 46 +++---- codex-rs/core/src/exec_policy_tests.rs | 116 ++++++---------- .../core/src/exec_policy_windows_tests.rs | 10 +- codex-rs/core/src/exec_tests.rs | 3 +- codex-rs/core/src/guardian/tests.rs | 7 +- codex-rs/core/src/safety_tests.rs | 125 +++++++++--------- codex-rs/core/src/session/tests.rs | 28 ++-- .../core/src/tools/network_approval_tests.rs | 5 +- .../runtimes/shell/unix_escalation_tests.rs | 31 ++--- .../core/tests/suite/request_permissions.rs | 109 +++++++++------ codex-rs/core/tests/suite/unified_exec.rs | 26 ++-- codex-rs/exec/tests/suite/sandbox.rs | 83 ++++++------ 15 files changed, 312 insertions(+), 376 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f3c56bfb3d..c744eb3f6e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -138,7 +138,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; @@ -367,9 +366,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo session_source: SessionSource::Exec, model: "gpt-5".to_string(), model_provider: "openai".to_string(), - permission_profile: CorePermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: CorePermissionProfile::read_only(), permission_profile_cwd: PathBuf::from("/tmp"), reasoning_effort: None, reasoning_summary: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9faa846137..4e4336a2b5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1255,8 +1255,7 @@ mod tests { use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; - use codex_protocol::protocol::NetworkAccess; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use pretty_assertions::assert_eq; @@ -1272,10 +1271,6 @@ mod tests { )?) } - fn profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1963,9 +1958,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2110,9 +2103,7 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); @@ -2195,29 +2186,25 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2228,11 +2215,9 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - } - )), + .can_set(&PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "ExternalSandbox".into(), @@ -2313,24 +2298,22 @@ allowed_approvals_reviewers = ["user"] let requirements = ConfigRequirements::try_from(requirements_with_sources)?; let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2361,9 +2344,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2402,9 +2383,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - )), + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 99da65b7c7..cd84f06cda 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -29,7 +29,6 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -821,6 +820,7 @@ flag = false async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> { use base64::Engine; + use codex_protocol::protocol::SandboxPolicy; let Some(home) = dirs::home_dir() else { return Ok(()); @@ -913,14 +913,7 @@ allowed_sandbox_modes = ["read-only"] state .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - )) + .can_set(&PermissionProfile::workspace_write()) .is_err() ); @@ -1233,11 +1226,9 @@ allowed_sandbox_modes = ["read-only"] let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( - config_requirements.permission_profile.can_set( - &PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - ) - ), + config_requirements + .permission_profile + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -1570,9 +1561,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: layers .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - )) + .can_set(&PermissionProfile::workspace_write()) .is_ok() ); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b8bd7d2398..ce7a36d93f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4303,8 +4303,7 @@ fn web_search_mode_disabled_overrides_legacy_request() { #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let permission_profile = - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()); + let permission_profile = PermissionProfile::read_only(); let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile); assert_eq!(mode, WebSearchMode::Cached); @@ -8926,29 +8925,26 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; - let constrained = Constrained::new( - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), - |candidate| { - if matches!( - candidate, - PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, - .. - } if entries - .iter() - .any(|entry| entry.access.can_write()) - ) { - Ok(()) - } else { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{candidate:?}"), - allowed: "[WorkspaceWrite]".to_string(), - requirement_source: RequirementSource::Unknown, - }) - } - }, - )?; + let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| { + if matches!( + candidate, + PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } if entries + .iter() + .any(|entry| entry.access.can_write()) + ) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 8c424c541c..7b0883db2a 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -23,7 +23,6 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -130,10 +129,6 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::external_sandbox() } -fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -669,7 +664,7 @@ async fn evaluates_bash_lc_inner_commands() { "rm -rf /some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -765,7 +760,7 @@ async fn evaluates_heredoc_script_against_prefix_rules() { policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -789,7 +784,7 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -813,7 +808,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ @@ -841,7 +836,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), @@ -866,7 +861,7 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -897,7 +892,7 @@ EOF"# .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -931,7 +926,7 @@ EOF"# .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -971,7 +966,7 @@ prefix_rule( "/some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -990,7 +985,7 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1018,7 +1013,7 @@ prefix_rule(pattern=["git"], decision="allow") policy_src: Some(policy_src), command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1052,7 +1047,7 @@ prefix_rule(pattern=["git"], decision="prompt") policy_src: Some(policy_src), command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1079,7 +1074,7 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { "cargo-insta".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1102,7 +1097,7 @@ async fn exec_approval_requirement_respects_approval_policy() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1130,9 +1125,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { request_permissions: true, mcp_elicitations: true, }), - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: &PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1175,9 +1168,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - ), + permission_profile: &PermissionProfile::workspace_write(), file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1258,7 +1249,7 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und "touch requested-dir/requested-but-unused.txt".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, @@ -1281,7 +1272,7 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re policy_src: None, command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1311,7 +1302,7 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1337,7 +1328,7 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1373,9 +1364,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() request_permissions: true, mcp_elicitations: true, }), - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1413,9 +1402,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( request_permissions: true, mcp_elicitations: true, }), - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1440,9 +1427,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1468,9 +1453,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1500,9 +1483,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1532,9 +1513,7 @@ async fn request_rule_uses_prefix_rule() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1683,7 +1662,7 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol policy_src: None, command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1703,7 +1682,7 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1727,7 +1706,7 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { "cargo build && echo ok".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1757,7 +1736,7 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri policy_src: Some(policy_src.to_string()), command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1781,7 +1760,7 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { policy_src: None, command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1805,7 +1784,7 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() "print(1)".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1836,7 +1815,7 @@ prefix_rule(pattern=["cat"], decision="allow") policy_src: Some(policy_src.to_string()), command: command.clone(), approval_policy, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1868,7 +1847,7 @@ prefix_rule(pattern=["bash"], decision="allow") .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -2034,7 +2013,7 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { policy_src: None, command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -2098,9 +2077,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2125,9 +2102,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2148,9 +2123,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::Never, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2170,8 +2143,8 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { policy_src: None, command, approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ExternalSandbox { - network_access: Default::default(), + permission_profile: PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }, file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -2195,8 +2168,8 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()), command, approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ExternalSandbox { - network_access: Default::default(), + permission_profile: PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }, file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -2214,7 +2187,7 @@ struct ExecApprovalRequirementScenario { policy_src: Option, command: Vec, approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, @@ -2238,7 +2211,7 @@ async fn exec_approval_requirement_for_command( policy_src, command, approval_policy, - sandbox_policy, + permission_profile, file_system_sandbox_policy, sandbox_permissions, prefix_rule, @@ -2246,7 +2219,6 @@ async fn exec_approval_requirement_for_command( let policy = policy_from_src(policy_src.as_deref()); - let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy); ExecPolicyManager::new(policy) .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index c1552f7e12..1d14d93813 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -14,7 +14,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { "echo blocked".to_string(), ], approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -38,7 +38,7 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { "echo blocked".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -80,9 +80,7 @@ fn unmatched_safe_powershell_words_are_allowed() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: &PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -111,7 +109,7 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { "Remove-Item test -Force".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 1e802121a1..268cbae971 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -347,8 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result ]; let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; - let sandbox_policy = SandboxPolicy::DangerFullAccess; - let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy); + let permission_profile = PermissionProfile::Disabled; let output = process_exec_tool_call( ExecParams { command, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 742b3c8ae4..0544b27770 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -42,7 +42,6 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnCompleteEvent; use core_test_support::PathBufExt; use core_test_support::TempDirExt; @@ -2191,7 +2190,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { ); assert_eq!( guardian_config.permissions.permission_profile(), - &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()) + &PermissionProfile::read_only() ); } @@ -2253,9 +2252,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( live_network, /*requirements*/ None, - &PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + &PermissionProfile::read_only(), ) .expect("live network proxy spec") ) diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index d699172498..ce51d8c437 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -1,20 +1,16 @@ use super::*; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; -fn permission_profile_for_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - #[test] fn test_writable_roots_constraint() { // Use a temporary directory as our workspace to avoid touching @@ -32,36 +28,34 @@ fn test_writable_roots_constraint() { // Policy limited to the workspace only; exclude system temp roots so // only `cwd` is writable by default. - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let workspace_only_file_system_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); assert!(is_write_patch_constrained_to_writable_paths( &add_inside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &workspace_only_file_system_policy, &cwd, )); assert!(!is_write_patch_constrained_to_writable_paths( &add_outside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &workspace_only_file_system_policy, &cwd, )); // With the parent dir explicitly added as a writable root, the // outside write should be permitted. - let policy_with_parent = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![parent], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let file_system_policy_with_parent = FileSystemSandboxPolicy::workspace_write( + std::slice::from_ref(&parent), + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); assert!(is_write_patch_constrained_to_writable_paths( &add_outside, - &FileSystemSandboxPolicy::from(&policy_with_parent), + &file_system_policy_with_parent, &cwd, )); } @@ -73,16 +67,17 @@ fn external_sandbox_auto_approves_in_on_request() { let add_inside_path = cwd.join("inner.txt"); let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string()); - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Enabled, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::external_sandbox(); assert_eq!( assess_patch_safety( &add_inside, AskForApproval::OnRequest, - &permission_profile_for_policy(&policy), - &FileSystemSandboxPolicy::from(&policy), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled ), @@ -100,19 +95,20 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert_eq!( assess_patch_safety( &add_outside, AskForApproval::OnRequest, - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -128,8 +124,8 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { request_permissions: true, mcp_elicitations: true, }), - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -144,12 +140,13 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert_eq!( assess_patch_safety( @@ -161,8 +158,8 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { request_permissions: true, mcp_elicitations: true, }), - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -178,9 +175,8 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { let cwd = tmp.path().abs(); let inside_path = cwd.join("inside.txt"); let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string()); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd); + let permission_profile = PermissionProfile::read_only(); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert!(!is_write_patch_constrained_to_writable_paths( &action, @@ -191,7 +187,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { assess_patch_safety( &action, AskForApproval::Never, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -208,8 +204,8 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { let blocked_path = cwd.join("blocked.txt"); let blocked_absolute = blocked_path; let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -235,7 +231,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -252,8 +248,8 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { let blocked_absolute = blocked_path; let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd); let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -279,7 +275,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -294,14 +290,21 @@ fn missing_project_dot_codex_config_requires_approval() { let cwd = tmp.path().abs(); let config_path = cwd.join(".codex").join("config.toml"); let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd); + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let mut file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + file_system_sandbox_policy + .entries + .push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: cwd.join(".codex"), + }, + access: FileSystemAccessMode::Read, + }); assert!(!is_write_patch_constrained_to_writable_paths( &action, @@ -312,7 +315,7 @@ fn missing_project_dot_codex_config_requires_approval() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 18277f468a..bb858999e4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -816,7 +816,6 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> { let (mut session, _turn_context) = make_session_and_context().await; let initial_permission_profile = PermissionProfile::workspace_write(); - let initial_policy = SandboxPolicy::new_workspace_write_policy(); let mut network_config = NetworkProxyConfig::default(); network_config @@ -860,11 +859,10 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow let mut state = session.state.lock().await; let mut config = (*state.session_configuration.original_config_do_not_use).clone(); config.permissions.network = Some(spec); - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(initial_policy.clone(), cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(initial_permission_profile.clone()) + .expect("test setup should allow permission profile"); state.session_configuration.original_config_do_not_use = Arc::new(config); state .session_configuration @@ -913,11 +911,10 @@ async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyho )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(PermissionProfile::Disabled) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; @@ -979,11 +976,10 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(PermissionProfile::Disabled) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); let layers = config @@ -1044,7 +1040,6 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an #[tokio::test] async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> { let permission_profile = PermissionProfile::workspace_write(); - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { @@ -1055,11 +1050,10 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(sandbox_policy, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(permission_profile) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; @@ -1072,7 +1066,6 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any #[tokio::test] async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> { let permission_profile = PermissionProfile::workspace_write(); - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { @@ -1083,11 +1076,10 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R )?; let (session, rx) = make_session_with_config_and_rx(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(sandbox_policy, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(permission_profile) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index a98d650688..25bcfe255d 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -4,7 +4,6 @@ use codex_network_proxy::BlockedRequestArgs; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -189,10 +188,10 @@ fn only_never_policy_disables_network_approval_flow() { #[test] fn network_approval_flow_is_limited_to_restricted_sandbox_modes() { assert!(permission_profile_allows_network_approval_flow( - &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()) + &PermissionProfile::read_only() )); assert!(permission_profile_allows_network_approval_flow( - &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) + &PermissionProfile::workspace_write() )); assert!(!permission_profile_allows_network_approval_flow( &PermissionProfile::Disabled diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 7c2aa5e8e5..e8a14083fc 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -28,7 +28,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianCommandSource; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; @@ -67,10 +66,6 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } -fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - fn test_sandbox_cwd() -> AbsolutePathBuf { AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap() } @@ -429,9 +424,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul call_id: "execve-hook-call".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -498,9 +491,7 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars ], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -553,9 +544,7 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() ], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -599,9 +588,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) &["git".to_string(), "status".to_string()], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -633,7 +620,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let program = AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(); let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let permission_profile = PermissionProfile::workspace_write(); let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); @@ -643,7 +630,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( &argv, InterceptedExecPolicyContext { approval_policy, - permission_profile: permission_profile_from_sandbox_policy(&sandbox_policy), + permission_profile: permission_profile.clone(), file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( @@ -659,7 +646,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( &argv, InterceptedExecPolicyContext { approval_policy, - permission_profile: permission_profile_from_sandbox_policy(&sandbox_policy), + permission_profile, file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, @@ -694,9 +681,7 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) &["git".to_string(), "status".to_string()], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 455c1fabb9..0463ea3e2b 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -7,6 +7,8 @@ use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; @@ -292,6 +294,15 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } +fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile { + CorePermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ) +} + fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { RequestPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( @@ -320,13 +331,14 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -417,13 +429,14 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio mcp_elicitations: true, }); let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::RequestPermissionsTool) @@ -502,13 +515,14 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -605,13 +619,14 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -707,13 +722,14 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -808,13 +824,14 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -914,13 +931,14 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1021,13 +1039,14 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1147,13 +1166,14 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1267,13 +1287,14 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1381,13 +1402,14 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::RequestPermissionsTool) @@ -1495,13 +1517,14 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1661,13 +1684,14 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1776,13 +1800,14 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 8687b33ff0..92b8aa9047 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -9,6 +9,7 @@ use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; @@ -890,11 +891,16 @@ mode = "limited" allow_local_binding = true "#, )?; - let mut sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - if let SandboxPolicy::WorkspaceWrite { network_access, .. } = &mut sandbox_policy { - *network_access = true; - } - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let sandbox_policy = permission_profile_for_config + .clone() + .to_legacy_sandbox_policy(home.path()) + .expect("workspace-write profile should project to legacy policy"); let mut builder = test_codex() .with_home(home) .with_cloud_requirements(managed_network_requirements_loader()) @@ -907,9 +913,7 @@ allow_local_binding = true config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); config .permissions - .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( - &sandbox_policy_for_config, - )) + .set_permission_profile(permission_profile_for_config) .expect("set permission profile"); }); let test = builder.build_with_remote_env(server).await?; @@ -2734,16 +2738,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { skip_if_sandbox!(Ok(())); let server = start_mock_server().await; - let read_only_policy = SandboxPolicy::new_read_only_policy(); - let read_only_policy_for_config = read_only_policy.clone(); let mut builder = test_codex().with_config(move |config| { config .features .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); - config - .set_legacy_sandbox_policy(read_only_policy_for_config) - .expect("set sandbox policy"); let mut file_system_sandbox_policy = FileSystemSandboxPolicy::default(); file_system_sandbox_policy .entries @@ -2800,6 +2799,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let read_only_policy = SandboxPolicy::new_read_only_policy(); codex .submit(Op::UserTurn { environments: None, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 8f8eac3237..0a5381b1ef 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -1,6 +1,7 @@ #![cfg(unix)] use codex_core::spawn::StdioPolicy; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use std::collections::HashMap; @@ -14,7 +15,7 @@ use tokio::process::Child; async fn spawn_command_under_sandbox( command: Vec, command_cwd: AbsolutePathBuf, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, stdio_policy: StdioPolicy, env: HashMap, @@ -24,7 +25,6 @@ async fn spawn_command_under_sandbox( use codex_core::exec::build_exec_request; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::models::PermissionProfile; use std::process::Stdio; let codex_linux_sandbox_exe = None; @@ -42,7 +42,7 @@ async fn spawn_command_under_sandbox( justification: None, arg0: None, }, - &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), + permission_profile, sandbox_cwd, &codex_linux_sandbox_exe, /*use_legacy_landlock*/ false, @@ -83,22 +83,20 @@ async fn spawn_command_under_sandbox( async fn spawn_command_under_sandbox( command: Vec, command_cwd: AbsolutePathBuf, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { use codex_core::spawn_command_under_linux_sandbox; - use codex_protocol::models::PermissionProfile; let codex_linux_sandbox_exe = core_test_support::find_codex_linux_sandbox_exe() .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; - let permission_profile = PermissionProfile::from_legacy_sandbox_policy(sandbox_policy); spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, command_cwd, - &permission_profile, + permission_profile, sandbox_cwd, /*use_legacy_landlock*/ false, stdio_policy, @@ -118,9 +116,16 @@ async fn spawn_command_under_sandbox( async fn linux_sandbox_test_env() -> Option> { let command_cwd = AbsolutePathBuf::current_dir().ok()?; let sandbox_cwd = command_cwd.clone(); - let policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = PermissionProfile::read_only(); - if can_apply_linux_sandbox_policy(&policy, &command_cwd, &sandbox_cwd, HashMap::new()).await { + if can_apply_linux_sandbox_policy( + &permission_profile, + &command_cwd, + &sandbox_cwd, + HashMap::new(), + ) + .await + { return Some(HashMap::new()); } @@ -135,7 +140,7 @@ async fn linux_sandbox_test_env() -> Option> { /// This is used as a capability probe so sandbox behavior tests only run when /// Landlock enforcement is actually active. async fn can_apply_linux_sandbox_policy( - policy: &SandboxPolicy, + permission_profile: &PermissionProfile, command_cwd: &AbsolutePathBuf, sandbox_cwd: &AbsolutePathBuf, env: HashMap, @@ -143,7 +148,7 @@ async fn can_apply_linux_sandbox_policy( let spawn_result = spawn_command_under_sandbox( vec!["/usr/bin/true".to_string()], command_cwd.clone(), - policy, + permission_profile, sandbox_cwd, StdioPolicy::RedirectForShellTool, env, @@ -180,12 +185,12 @@ async fn python_multiprocessing_lock_works_under_sandbox() { #[cfg(target_os = "linux")] let writable_roots: Vec = vec!["/dev/shm".try_into().unwrap()]; - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &writable_roots, + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let python_code = r#"import multiprocessing from multiprocessing import Lock, Process @@ -210,7 +215,7 @@ if __name__ == '__main__': python_code.to_string(), ], command_cwd, - &policy, + &permission_profile, &sandbox_cwd, StdioPolicy::Inherit, sandbox_env, @@ -242,7 +247,7 @@ async fn python_getpwuid_works_under_sandbox() { return; } - let policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = PermissionProfile::read_only(); let command_cwd = AbsolutePathBuf::current_dir().expect("should be able to get current dir"); let sandbox_cwd = command_cwd.clone(); @@ -253,7 +258,7 @@ async fn python_getpwuid_works_under_sandbox() { "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), ], command_cwd, - &policy, + &permission_profile, &sandbox_cwd, StdioPolicy::RedirectForShellTool, sandbox_env, @@ -294,12 +299,12 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { // Note writable_roots is empty: verify that `canonical_allowed_path` is // writable only because it is under the sandbox policy cwd, not because it // is under a writable root. - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); // Attempt to write inside the command cwd, which is outside of the sandbox policy cwd. let mut child = spawn_command_under_sandbox( @@ -309,7 +314,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { "echo forbidden > forbidden.txt".to_string(), ], command_root.clone(), - &policy, + &permission_profile, &canonical_sandbox_root, StdioPolicy::Inherit, sandbox_env.clone(), @@ -340,7 +345,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { canonical_allowed_path.to_string_lossy().into_owned(), ], command_root, - &policy, + &permission_profile, &canonical_sandbox_root, StdioPolicy::Inherit, sandbox_env, @@ -375,12 +380,12 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { create_dir_all(&repo_root).await.expect("mkdir repo"); let dot_codex = repo_root.join(".codex"); let config_toml = dot_codex.join("config.toml"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let mut child = spawn_command_under_sandbox( vec![ @@ -390,7 +395,7 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { .to_string(), ], repo_root.clone(), - &policy, + &permission_profile, &repo_root, StdioPolicy::RedirectForShellTool, sandbox_env, @@ -507,7 +512,7 @@ fn unix_sock_body() { async fn allow_unix_socketpair_recvfrom() { run_code_under_sandbox( "allow_unix_socketpair_recvfrom", - &SandboxPolicy::new_read_only_policy(), + &PermissionProfile::read_only(), || async { unix_sock_body() }, ) .await @@ -519,7 +524,7 @@ const IN_SANDBOX_ENV_VAR: &str = "IN_SANDBOX"; #[expect(clippy::expect_used)] pub async fn run_code_under_sandbox( test_selector: &str, - policy: &SandboxPolicy, + permission_profile: &PermissionProfile, child_body: F, ) -> io::Result> where @@ -544,7 +549,7 @@ where let mut child = spawn_command_under_sandbox( cmds, command_cwd, - policy, + permission_profile, &sandbox_cwd, stdio_policy, HashMap::from([("IN_SANDBOX".into(), "1".into())]), From b200dd1b6f3492a122a7f45d4130ec80d372c745 Mon Sep 17 00:00:00 2001 From: Michael Zeng Date: Sat, 16 May 2026 12:48:28 -0700 Subject: [PATCH 036/114] exec-server: support auth-backed remote executor registration (#22769) This updates remote `exec-server` registration to use normal Codex auth instead of a registry-issued credential. The registry request is built from the existing auth-provider path, which preserves the biscuit-only registry contract introduced in [openai/openai#924101](https://github.com/openai/openai/pull/924101) while removing the old remote registry bearer env var and its direct transport assumptions. The default remote flow uses persisted ChatGPT auth from the normal Codex config/storage path. This PR also includes the containerized Agent Identity path needed by [openai/openai#924260](https://github.com/openai/openai/pull/924260): remote `exec-server` accepts `--allow-agent-identity-auth`, permits Agent Identity auth loaded from `CODEX_ACCESS_TOKEN` only when that flag is present, and reuses the existing Agent task registration plus derived `AgentAssertion` header generation. API-key auth remains unsupported, and Agent Identity stays opt-in. Validation performed beyond normal presubmit coverage: - `cargo fmt --all --check` - `cargo check -p codex-cli` - `cargo test -p codex-exec-server` - `cargo test -p codex-cli exec_server_agent_identity_auth_flag_` - `cargo test -p codex-cli remote_exec_server_auth_mode_` I also attempted `cargo test -p codex-cli`. The new CLI tests passed inside that run, but the suite ended on an unrelated local marketplace-state failure in `plugin_list_excludes_unconfigured_repo_local_marketplaces`. --- codex-rs/Cargo.lock | 2 + codex-rs/cli/src/main.rs | 93 ++++++++++++++++++++++++- codex-rs/exec-server/Cargo.toml | 2 + codex-rs/exec-server/README.md | 10 ++- codex-rs/exec-server/src/lib.rs | 1 - codex-rs/exec-server/src/remote.rs | 103 +++++++++++++--------------- codex-rs/exec-server/tests/relay.rs | 24 ++++++- 7 files changed, 173 insertions(+), 62 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb2439401b..83ca0d1191 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2748,6 +2748,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", + "codex-api", "codex-app-server-protocol", "codex-client", "codex-file-system", @@ -2759,6 +2760,7 @@ dependencies = [ "codex-utils-rustls-provider", "ctor 0.6.3", "futures", + "http 1.4.0", "pretty_assertions", "prost 0.14.3", "reqwest", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ffdff0011..6c79c34c99 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -73,6 +73,8 @@ use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::read_codex_access_token_from_env; use codex_memories_write::clear_memory_roots_contents; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; @@ -489,6 +491,10 @@ struct ExecServerCommand { /// Human-readable executor name. #[arg(long = "name", value_name = "NAME")] name: Option, + + /// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration. + #[arg(long = "use-agent-identity-auth", requires = "remote")] + use_agent_identity_auth: bool, } #[derive(Debug, clap::Subcommand)] @@ -1388,7 +1394,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "exec-server", )?; - run_exec_server_command(cmd, &arg0_paths).await?; + run_exec_server_command( + cmd, + &arg0_paths, + &root_config_overrides, + interactive.config_profile.clone(), + ) + .await?; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { @@ -1485,6 +1497,8 @@ fn profile_v2_for_subcommand<'a>( async fn run_exec_server_command( cmd: ExecServerCommand, arg0_paths: &Arg0DispatchPaths, + root_config_overrides: &CliConfigOverrides, + config_profile: Option, ) -> anyhow::Result<()> { let codex_self_exe = arg0_paths .codex_self_exe @@ -1498,8 +1512,14 @@ async fn run_exec_server_command( let executor_id = cmd .executor_id .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let auth_provider = load_exec_server_remote_auth_provider( + root_config_overrides, + config_profile, + cmd.use_agent_identity_auth, + ) + .await?; let mut remote_config = - codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id, auth_provider)?; if let Some(name) = cmd.name { remote_config.name = name; } @@ -1515,6 +1535,75 @@ async fn run_exec_server_command( .map_err(anyhow::Error::from_boxed) } +async fn load_exec_server_remote_auth_provider( + root_config_overrides: &CliConfigOverrides, + config_profile: Option, + use_agent_identity_auth: bool, +) -> anyhow::Result { + let config = load_exec_server_remote_config(root_config_overrides, config_profile).await?; + if use_agent_identity_auth { + let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { + anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") + })?; + let auth = + CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) + .await?; + return Ok(codex_model_provider::auth_provider_from_auth(&auth)); + } + + let auth = load_exec_server_remote_auth( + &config, + "remote exec-server registration requires ChatGPT authentication; run `codex login` first", + ) + .await?; + + if !auth.is_chatgpt_auth() { + anyhow::bail!( + "remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported" + ); + } + + Ok(codex_model_provider::auth_provider_from_auth(&auth)) +} + +async fn load_exec_server_remote_config( + root_config_overrides: &CliConfigOverrides, + config_profile: Option, +) -> anyhow::Result { + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + Ok(ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(ConfigOverrides { + config_profile, + ..Default::default() + }) + .build() + .await?) +} + +async fn load_exec_server_remote_auth( + config: &codex_core::config::Config, + missing_auth_error: &'static str, +) -> anyhow::Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + + let auth = match auth_manager.auth().await { + Some(auth) => auth, + None => { + auth_manager.reload().await; + auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? + } + }; + + Ok(auth) +} + async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { FeatureToggles::validate_feature(feature)?; let codex_home = find_codex_home()?; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 09a9a71ea0..d842094a16 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,6 +17,7 @@ axum = { workspace = true, features = ["http1", "tokio", "ws"] } base64 = { workspace = true } bytes = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-api = { workspace = true } codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } @@ -51,6 +52,7 @@ uuid = { workspace = true, features = ["v4"] } anyhow = { workspace = true } codex-test-binary-support = { workspace = true } ctor = { workspace = true } +http = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 1eaf6e69eb..01439108f8 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -26,7 +26,11 @@ The CLI entrypoint supports: Remote mode registers the local exec-server with the executor registry, then reconnects to the service-provided rendezvous websocket as the executor. -It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`. +It uses the standard Codex ChatGPT sign-in state; run `codex login` first when +remote registration needs authentication. Containerized callers that receive an +Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with +`--use-agent-identity-auth`; Codex then registers an Agent task and sends the +derived AgentAssertion headers on the registry request. Wire framing: @@ -376,7 +380,9 @@ The crate exports: Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level `codex exec-server` command builds these paths from the `codex` arg0 dispatch -state. +state. `RemoteExecutorConfig::new(...)` also takes the auth provider that +remote registration should use; the CLI builds that provider from Codex auth +state before starting remote mode. ## Example session diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 872f16ce32..bd556638ea 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -91,7 +91,6 @@ pub use protocol::TerminateResponse; pub use protocol::WriteParams; pub use protocol::WriteResponse; pub use protocol::WriteStatus; -pub use remote::CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR; pub use remote::RemoteExecutorConfig; pub use remote::run_remote_executor; pub use runtime_paths::ExecServerRuntimePaths; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 32c0d5bc8e..2493135c9a 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,6 +1,6 @@ -use std::env; use std::time::Duration; +use codex_api::SharedAuthProvider; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::sleep; @@ -14,15 +14,12 @@ use crate::ExecServerRuntimePaths; use crate::relay::run_multiplexed_executor; use crate::server::ConnectionProcessor; -pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str = - "CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN"; - const ERROR_BODY_PREVIEW_BYTES: usize = 4096; #[derive(Clone)] struct ExecutorRegistryClient { base_url: String, - bearer_token: String, + auth_provider: SharedAuthProvider, http: reqwest::Client, } @@ -30,17 +27,17 @@ impl std::fmt::Debug for ExecutorRegistryClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExecutorRegistryClient") .field("base_url", &self.base_url) - .field("bearer_token", &"") + .field("auth_provider", &"") .finish_non_exhaustive() } } impl ExecutorRegistryClient { - fn new(base_url: String, bearer_token: String) -> Result { + fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result { let base_url = normalize_base_url(base_url)?; Ok(Self { base_url, - bearer_token, + auth_provider, http: reqwest::Client::new(), }) } @@ -55,7 +52,7 @@ impl ExecutorRegistryClient { &self.base_url, &format!("/cloud/executor/{executor_id}/register"), )) - .bearer_auth(&self.bearer_token) + .headers(self.auth_provider.to_auth_headers()) .send() .await?; self.parse_json_response(response).await @@ -89,12 +86,12 @@ struct ExecutorRegistryExecutorRegistrationResponse { } /// Configuration for registering an exec-server for remote use. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone)] pub struct RemoteExecutorConfig { pub base_url: String, pub executor_id: String, pub name: String, - bearer_token: String, + auth_provider: SharedAuthProvider, } impl std::fmt::Debug for RemoteExecutorConfig { @@ -103,28 +100,23 @@ impl std::fmt::Debug for RemoteExecutorConfig { .field("base_url", &self.base_url) .field("executor_id", &self.executor_id) .field("name", &self.name) - .field("bearer_token", &"") + .field("auth_provider", &"") .finish() } } impl RemoteExecutorConfig { - pub fn new(base_url: String, executor_id: String) -> Result { - Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?) - } - - pub fn with_bearer_token( + pub fn new( base_url: String, executor_id: String, - bearer_token: String, + auth_provider: SharedAuthProvider, ) -> Result { let executor_id = normalize_executor_id(executor_id)?; - let bearer_token = normalize_bearer_token(bearer_token)?; Ok(Self { base_url, executor_id, name: "codex-exec-server".to_string(), - bearer_token, + auth_provider, }) } } @@ -136,7 +128,8 @@ pub async fn run_remote_executor( runtime_paths: ExecServerRuntimePaths, ) -> Result<(), ExecServerError> { ensure_rustls_crypto_provider(); - let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?; + let client = + ExecutorRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?; let processor = ConnectionProcessor::new(runtime_paths); let mut backoff = Duration::from_secs(1); @@ -162,32 +155,6 @@ pub async fn run_remote_executor( } } -fn read_remote_bearer_token_from_env() -> Result { - read_remote_bearer_token_from_env_with(|name| env::var(name)) -} - -fn read_remote_bearer_token_from_env_with(get_var: F) -> Result -where - F: FnOnce(&str) -> Result, -{ - let bearer_token = get_var(CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR).map_err(|_| { - ExecServerError::ExecutorRegistryAuth(format!( - "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is not set" - )) - })?; - normalize_bearer_token(bearer_token) -} - -fn normalize_bearer_token(bearer_token: String) -> Result { - let bearer_token = bearer_token.trim().to_string(); - if bearer_token.is_empty() { - return Err(ExecServerError::ExecutorRegistryAuth(format!( - "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is empty" - ))); - } - Ok(bearer_token) -} - fn normalize_executor_id(executor_id: String) -> Result { let executor_id = executor_id.trim().to_string(); if executor_id.is_empty() { @@ -274,6 +241,11 @@ fn preview_error_body(body: &str) -> Option { #[cfg(test)] mod tests { + use std::sync::Arc; + + use codex_api::AuthProvider; + use http::HeaderMap; + use http::HeaderValue; use pretty_assertions::assert_eq; use wiremock::Mock; use wiremock::MockServer; @@ -284,25 +256,46 @@ mod tests { use super::*; + #[derive(Debug)] + struct StaticRegistryAuthProvider; + + impl AuthProvider for StaticRegistryAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let _ = headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer registry-token"), + ); + let _ = headers.insert( + "ChatGPT-Account-ID", + HeaderValue::from_static("workspace-123"), + ); + } + } + + fn static_registry_auth_provider() -> SharedAuthProvider { + Arc::new(StaticRegistryAuthProvider) + } + #[tokio::test] - async fn register_executor_posts_with_bearer_token_header() { + async fn register_executor_posts_with_auth_provider_headers() { let server = MockServer::start().await; - let config = RemoteExecutorConfig::with_bearer_token( + let config = RemoteExecutorConfig::new( server.uri(), "exec-requested".to_string(), - "registry-token".to_string(), + static_registry_auth_provider(), ) .expect("config"); Mock::given(method("POST")) .and(path("/cloud/executor/exec-requested/register")) .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "executor_id": "exec-1", "url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc" }))) .mount(&server) .await; - let client = ExecutorRegistryClient::new(server.uri(), "registry-token".to_string()) + let client = ExecutorRegistryClient::new(server.uri(), static_registry_auth_provider()) .expect("client"); let response = client @@ -320,17 +313,17 @@ mod tests { } #[test] - fn debug_output_redacts_bearer_token() { - let config = RemoteExecutorConfig::with_bearer_token( + fn debug_output_redacts_auth_provider() { + let config = RemoteExecutorConfig::new( "https://registry.example".to_string(), "exec-1".to_string(), - "secret-token".to_string(), + static_registry_auth_provider(), ) .expect("config"); let debug = format!("{config:?}"); assert!(debug.contains("")); - assert!(!debug.contains("secret-token")); + assert!(!debug.contains("workspace-123")); } } diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index d228db9ee5..9f985e6982 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -10,6 +10,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use codex_api::AuthProvider; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; @@ -22,12 +23,15 @@ use codex_exec_server::InitializeResponse; use codex_exec_server::RemoteExecutorConfig; use futures::SinkExt; use futures::StreamExt; +use http::HeaderMap; +use http::HeaderValue; use pretty_assertions::assert_eq; use prost::Message as ProstMessage; use relay_proto::RelayData; use relay_proto::RelayMessageFrame; use relay_proto::RelayReset; use relay_proto::relay_message_frame; +use std::sync::Arc; use tokio::net::TcpListener; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; @@ -46,6 +50,22 @@ const REGISTRY_TOKEN: &str = "registry-token"; const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; const TEST_TIMEOUT: Duration = Duration::from_secs(5); +#[derive(Debug)] +struct StaticRegistryAuthProvider; + +impl AuthProvider for StaticRegistryAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let _ = headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer registry-token"), + ); + } +} + +fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { + Arc::new(StaticRegistryAuthProvider) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -63,10 +83,10 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?; let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?; - let config = RemoteExecutorConfig::with_bearer_token( + let config = RemoteExecutorConfig::new( registry.uri(), EXECUTOR_ID.to_string(), - REGISTRY_TOKEN.to_string(), + static_registry_auth_provider(), )?; let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor( config, From 6941f5c2c5a17a6f61bfbc9f4c96cb0c81401623 Mon Sep 17 00:00:00 2001 From: Miaolin Min Date: Sat, 16 May 2026 13:27:44 -0700 Subject: [PATCH 037/114] [codex] preserve MCP result meta in McpToolCallItemResult (#22946) ## Summary https://openai.slack.com/archives/C0ARA9UAQEA/p1778890981647319?thread_ts=1778888537.934319&cid=C0ARA9UAQEA - Add `_meta` to exec JSONL MCP tool call result events. - Copy MCP result metadata through the JSONL event conversion. - Add a focused test that verifies `_meta` is serialized as `_meta` and not `meta`. ## Verification https://www.notion.so/openai/Miaolin-0516-_meta-population-debug-3628e50b62b08074b365e0ce1ffb8f74 --- .../src/event_processor_with_jsonl_output.rs | 1 + ...event_processor_with_jsonl_output_tests.rs | 51 +++++++++++++++++++ codex-rs/exec/src/exec_events.rs | 3 ++ .../tests/event_processor_with_json_output.rs | 2 + sdk/typescript/src/items.ts | 1 + 5 files changed, 58 insertions(+) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 045baacc7b..89e41eacb1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -223,6 +223,7 @@ impl EventProcessorWithJsonOutput { arguments, result: result.map(|result| McpToolCallItemResult { content: result.content, + meta: result.meta, structured_content: result.structured_content, }), error: error.map(|error| McpToolCallItemError { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index 88fd042f7c..6bd358f6be 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::tempdir; #[test] @@ -57,3 +58,53 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { "keep existing contents" ); } + +#[test] +fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { + let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "search service".to_string(), + tool: "web_run".to_string(), + status: McpToolCallStatus::Completed, + arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}), + mcp_app_resource_uri: None, + result: Some(Box::new(codex_app_server_protocol::McpToolCallResult { + content: vec![json!({"type": "text", "text": "search result"})], + structured_content: None, + meta: Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})), + })), + error: None, + duration_ms: Some(42), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 0, + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(collected.events.len(), 1); + + let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else { + panic!("expected item.completed event"); + }; + let ThreadItemDetails::McpToolCall(item) = &item.details else { + panic!("expected MCP tool call item"); + }; + let result = item.result.as_ref().expect("expected MCP tool result"); + assert_eq!( + result.meta, + Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})) + ); + + let serialized = serde_json::to_value(&collected.events[0]).expect("serialize event"); + assert_eq!( + serialized["item"]["result"]["_meta"], + json!({"raw_messages": [{"ref_id": "turn0search0"}]}) + ); + assert!(serialized["item"]["result"].get("meta").is_none()); +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 4a84ef7494..078a876ef4 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -266,6 +266,9 @@ pub struct McpToolCallItemResult { // representations). Using `JsonValue` keeps the payload wire-shaped and // easy to export. pub content: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, pub structured_content: Option, } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index efda58f412..acbac8254c 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -540,6 +540,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { arguments: json!({ "key": "value" }), result: Some(McpToolCallItemResult { content: Vec::new(), + meta: None, structured_content: None, }), error: None, @@ -681,6 +682,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { "type": "text", "text": "done", })], + meta: None, structured_content: Some(json!({ "status": "ok" })), }), error: None, diff --git a/sdk/typescript/src/items.ts b/sdk/typescript/src/items.ts index 182878f55b..8fd3b2c7ff 100644 --- a/sdk/typescript/src/items.ts +++ b/sdk/typescript/src/items.ts @@ -60,6 +60,7 @@ export type McpToolCallItem = { /** Result payload returned by the MCP server for successful calls. */ result?: { content: McpContentBlock[]; + _meta?: unknown; structured_content: unknown; }; /** Error message reported for failed calls. */ From 061a614d857e0f2289457770e90caf8a45b5e430 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sat, 16 May 2026 13:43:30 -0700 Subject: [PATCH 038/114] multiagent: trim model-visible description, cap to 5 models (#23069) ## Why The `spawn_agent` model override guidance is uncapped and bloating context. We need to trim down each entry and cap total entries. picked 5 as cap, we can change ## What changed - Cap the model override summaries shown in `spawn_agent` to the first 5 picker-visible models, preserving the existing priority ordering from the models manager. - Condense each rendered entry to the actionable pieces the model needs: - use the model slug as the label - render compact reasoning effort lists with the default marked inline - render only service tier IDs, and omit the clause when no tiers are available - Update coverage so the compact formatter shape and the top-5 cap are exercised, and keep the end-to-end request assertion aligned with real model metadata. ## Example Before: `- gpt-5.4 ('gpt-5.4\'): Strong model for everyday coding. Default reasoning effort: medium. Supported reasoning efforts: low (Fast responses with lighter reasoning), medium (Balances speed and reasoning depth for everyday tasks), high (Greater reasoning depth for complex problems), xhigh (Extra high reasoning depth for complex problems). Supported service tiers: priority (Fast: 1.5x speed, increased usage).` After: `- 'gpt-5.4': Strong model for everyday coding. Reasoning efforts: low, medium (default), high, xhigh. Service tiers: priority.` --- .../src/tools/handlers/multi_agents_spec.rs | 50 ++++++++++++------- .../tools/handlers/multi_agents_spec_tests.rs | 41 ++++++++++++--- .../tests/suite/spawn_agent_description.rs | 17 +++---- 3 files changed, 75 insertions(+), 33 deletions(-) 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 111ab67798..e1295b50de 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."; const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; +const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -711,8 +712,11 @@ The new agent's canonical task name will be provided to it along with the messag } fn spawn_agent_models_description(models: &[ModelPreset]) -> String { - let visible_models: Vec<&ModelPreset> = - models.iter().filter(|model| model.show_in_picker).collect(); + let visible_models: Vec<&ModelPreset> = models + .iter() + .filter(|model| model.show_in_picker) + .take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION) + .collect(); if visible_models.is_empty() { return "No picker-visible model overrides are currently loaded.".to_string(); } @@ -720,30 +724,40 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String { let model_descriptions = visible_models .into_iter() .map(|model| { + let default_reasoning_effort = model.default_reasoning_effort; let efforts = model .supported_reasoning_efforts .iter() - .map(|preset| format!("{} ({})", preset.effort, preset.description)) + .map(|preset| { + let effort = preset.effort; + if effort == default_reasoning_effort { + format!("{effort} (default)") + } else { + effort.to_string() + } + }) .collect::>() .join(", "); - let service_tiers = if model.service_tiers.is_empty() { - "none".to_string() + let reasoning_efforts_suffix = if efforts.is_empty() { + String::new() } else { - model - .service_tiers - .iter() - .map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description)) - .collect::>() - .join(", ") + format!(" Reasoning efforts: {efforts}.") }; + let service_tiers = model + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .join(", "); + let service_tiers_suffix = if service_tiers.is_empty() { + String::new() + } else { + format!(" Service tiers: {service_tiers}.") + }; + let model_slug = &model.model; + let description = &model.description; format!( - "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.", - model.display_name, - model.model, - model.description, - model.default_reasoning_effort, - efforts, - service_tiers + "- `{model_slug}`: {description}{reasoning_efforts_suffix}{service_tiers_suffix}" ) }) .collect::>() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index aaa29d6d4c..711db2fd11 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -74,12 +74,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { description .contains("Available model overrides (optional; inherited parent model is preferred):") ); - assert!(description.contains("visible display (`visible-model`)")); - assert!( - description - .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).") - ); - assert!(!description.contains("hidden display (`hidden-model`)")); + assert!(description.contains( + "- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority." + )); + assert!(!description.contains("hidden-model")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); assert!(properties.contains_key("fork_turns")); @@ -150,6 +148,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { ); } +#[test] +fn spawn_agent_tool_caps_visible_model_summaries() { + let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { + available_models: vec![ + model_preset("first", /*show_in_picker*/ true), + model_preset("second", /*show_in_picker*/ true), + model_preset("third", /*show_in_picker*/ true), + model_preset("fourth", /*show_in_picker*/ true), + model_preset("fifth", /*show_in_picker*/ true), + model_preset("sixth", /*show_in_picker*/ true), + ], + agent_type_description: "role help".to_string(), + hide_agent_type_model_reasoning: false, + include_usage_hint: true, + usage_hint_text: None, + max_concurrent_threads_per_session: Some(4), + }); + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else { + panic!("spawn_agent should be a function tool"); + }; + + for model in ["first", "second", "third", "fourth", "fifth"] { + assert!( + description.contains(&format!("`{model}-model`")), + "expected {model} model summary in spawn_agent description: {description:?}" + ); + } + assert!(!description.contains("`sixth-model`")); +} + #[test] fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 5aab77cd7d..c70f9bce37 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -123,6 +123,10 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> effort: ReasoningEffort::Low, description: "Quick scan".to_string(), }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Balanced".to_string(), + }, ReasoningEffortPreset { effort: ReasoningEffort::High, description: "Deep dive".to_string(), @@ -175,7 +179,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> spawn_agent_description(&body).expect("spawn_agent description should be present"); assert!( - description.contains("- Visible Model (`visible-model`): Fast and capable"), + description.contains("- `visible-model`: Fast and capable"), "expected visible model summary in spawn_agent description: {description:?}" ); assert!( @@ -196,20 +200,15 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> "expected model override usage guidance in spawn_agent description: {description:?}" ); assert!( - description.contains("Default reasoning effort: medium."), + description.contains("Reasoning efforts: low, medium (default), high."), "expected default reasoning effort in spawn_agent description: {description:?}" ); assert!( - description.contains("low (Quick scan), high (Deep dive)."), - "expected reasoning efforts in spawn_agent description: {description:?}" - ); - assert!( - description - .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."), + description.contains("Service tiers: priority."), "expected service tier guidance in spawn_agent description: {description:?}" ); assert!( - !description.contains("Hidden Model"), + !description.contains("hidden-model"), "hidden picker model should be omitted from spawn_agent description: {description:?}" ); assert!( From 0445b290fe0b6f1c8d13863af4b27202aca09005 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 14:27:02 -0700 Subject: [PATCH 039/114] [1 of 4] tui: route primary settings writes through app server (#22913) ## Why The TUI can run against a remote app server, but several high-traffic settings still persisted by editing the local config file. That sends remote sessions' preference writes to the wrong machine and lets local disk state drift from the app-server-owned config. This is **[1 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Added a small TUI helper for typed app-server config writes. - Routed primary interactive preference writes through `config/batchWrite`. - Preserved existing profile scoping for settings that already support `profiles..*` overrides. ## Config keys affected - `model` - `model_reasoning_effort` - `personality` - `service_tier` - `plan_mode_reasoning_effort` - `approvals_reviewer` - `notice.fast_default_opt_out` - Profile-scoped equivalents under `profiles..*` ## Suggested manual validation - Connect the TUI to a remote app server, change `model` and `model_reasoning_effort`, reconnect, and confirm the remote config retained both values while the local `config.toml` did not change. - Change `personality`, `plan_mode_reasoning_effort`, and the explicit auto-review selection, then reconnect and confirm those choices persist through the app server. - Clear the service tier back to default and confirm `service_tier` is cleared while `notice.fast_default_opt_out = true` is persisted remotely. - Repeat one setting change with an active profile and confirm the write lands under `profiles..*`. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping --- .../app-server/src/config_manager_service.rs | 45 ++++++- .../app-server/tests/suite/v2/config_rpc.rs | 65 +++++++++ codex-rs/tui/src/app/event_dispatch.rs | 99 +++++++------- codex-rs/tui/src/config_update.rs | 124 ++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + 5 files changed, 280 insertions(+), 54 deletions(-) create mode 100644 codex-rs/tui/src/config_update.rs diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 64c5bee684..4255b83e62 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -403,10 +403,47 @@ fn parse_key_path(path: &str) -> Result, String> { if path.trim().is_empty() { return Err("keyPath must not be empty".to_string()); } - Ok(path - .split('.') - .map(std::string::ToString::to_string) - .collect()) + + let mut segments = Vec::new(); + let mut segment = String::new(); + let mut chars = path.chars(); + let mut quoted = false; + + // Split on dots unless they appear inside a quoted segment. Bare segments + // intentionally stay permissive so existing paths like `sample@catalog` + // remain valid. + while let Some(ch) = chars.next() { + match ch { + '"' if segment.is_empty() && !quoted => quoted = true, + '"' if quoted => quoted = false, + '\\' if quoted => { + // Quoted segments may escape punctuation that would otherwise + // participate in parsing, such as `.` or `"`. + let Some(escaped) = chars.next() else { + return Err("unterminated escape in keyPath".to_string()); + }; + segment.push(escaped); + } + '.' if !quoted => { + if segment.is_empty() { + return Err("keyPath segments must not be empty".to_string()); + } + segments.push(std::mem::take(&mut segment)); + } + '"' => return Err("invalid quoted keyPath segment".to_string()), + _ => segment.push(ch), + } + } + + if quoted { + return Err("unterminated quoted keyPath segment".to_string()); + } + if segment.is_empty() { + return Err("keyPath segments must not be empty".to_string()); + } + + segments.push(segment); + Ok(segments) } #[derive(Debug)] diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 38ba12f95f..56c4d0b7d1 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -893,6 +893,71 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_preserves_dotted_profile_names() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config( + &tmp_dir, + r#" +profile = "team.prod" + +[profiles."team.prod"] +model = "gpt-5.3-spark" + +[profiles.team.prod] +model = "should-stay-put" +"#, + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "profiles.\"team.prod\".model".to_string(), + value: json!("gpt-5.5"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "items.sample@catalog.enabled".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + + let config: toml::Value = + toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; + assert_eq!( + config["profiles"]["team.prod"]["model"].as_str(), + Some("gpt-5.5") + ); + assert_eq!( + config["profiles"]["team"]["prod"]["model"].as_str(), + Some("should-stay-put") + ); + assert_eq!( + config["items"]["sample@catalog"]["enabled"].as_bool(), + Some(true) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { let tmp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 15e4f9b6cd..9271089c43 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1183,11 +1183,15 @@ impl App { } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_model(Some(model.as_str()), effort) - .apply() - .await + match crate::config_update::write_config_batch( + app_server.request_handle(), + crate::config_update::build_model_selection_edits( + profile, + model.as_str(), + effort, + ), + ) + .await { Ok(()) => { let effort_label = effort @@ -1261,11 +1265,14 @@ impl App { } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_personality(Some(personality)) - .apply() - .await + match crate::config_update::write_config_batch( + app_server.request_handle(), + vec![crate::config_update::replace_config_value( + crate::config_update::profile_scoped_key_path(profile, "personality"), + serde_json::json!(personality.to_string()), + )], + ) + .await { Ok(()) => { let label = Self::personality_label(personality); @@ -1298,14 +1305,16 @@ impl App { self.refresh_status_line(); let profile = self.active_profile.as_deref(); self.config.service_tier = service_tier.clone(); - let mut edits = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_service_tier(service_tier.clone()); + let edits = crate::config_update::build_service_tier_selection_edits( + profile, + service_tier.as_deref(), + ); if service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); - edits = edits.set_fast_default_opt_out(/*opted_out*/ true); } - match edits.apply().await { + match crate::config_update::write_config_batch(app_server.request_handle(), edits) + .await + { Ok(()) => { let mut message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") @@ -1476,23 +1485,17 @@ impl App { self.sync_active_thread_permission_settings_to_cached_session() .await; let profile = self.active_profile.as_deref(); - let segments = if let Some(profile) = profile { - vec![ - "profiles".to_string(), - profile.to_string(), - "approvals_reviewer".to_string(), - ] - } else { - vec!["approvals_reviewer".to_string()] - }; - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .with_edits([ConfigEdit::SetPath { - segments, - value: policy.to_string().into(), - }]) - .apply() - .await + if let Err(err) = crate::config_update::write_config_batch( + app_server.request_handle(), + vec![crate::config_update::replace_config_value( + crate::config_update::profile_scoped_key_path( + profile, + "approvals_reviewer", + ), + serde_json::json!(policy.to_string()), + )], + ) + .await { tracing::error!( error = %err, @@ -1583,27 +1586,23 @@ impl App { } AppEvent::PersistPlanModeReasoningEffort(effort) => { let profile = self.active_profile.as_deref(); - let segments = if let Some(profile) = profile { - vec![ - "profiles".to_string(), - profile.to_string(), - "plan_mode_reasoning_effort".to_string(), - ] - } else { - vec!["plan_mode_reasoning_effort".to_string()] - }; + let key_path = crate::config_update::profile_scoped_key_path( + profile, + "plan_mode_reasoning_effort", + ); let edit = if let Some(effort) = effort { - ConfigEdit::SetPath { - segments, - value: effort.to_string().into(), - } + crate::config_update::replace_config_value( + key_path, + serde_json::json!(effort.to_string()), + ) } else { - ConfigEdit::ClearPath { segments } + crate::config_update::clear_config_value(key_path) }; - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_edits([edit]) - .apply() - .await + if let Err(err) = crate::config_update::write_config_batch( + app_server.request_handle(), + vec![edit], + ) + .await { tracing::error!( error = %err, diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs new file mode 100644 index 0000000000..bcdbe29a0d --- /dev/null +++ b/codex-rs/tui/src/config_update.rs @@ -0,0 +1,124 @@ +//! App-server-backed config update helpers for the TUI. +//! +//! This module centralizes the small typed update helpers the TUI uses +//! when a config mutation must be owned by the app server rather than written +//! to the local `config.toml` directly. + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::RequestId; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +pub(crate) fn replace_config_value(key_path: impl Into, value: JsonValue) -> ConfigEdit { + ConfigEdit { + key_path: key_path.into(), + value, + merge_strategy: MergeStrategy::Replace, + } +} + +pub(crate) fn clear_config_value(key_path: impl Into) -> ConfigEdit { + replace_config_value(key_path, JsonValue::Null) +} + +pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> String { + if let Some(profile) = profile { + let profile = serde_json::Value::String(profile.to_string()).to_string(); + format!("profiles.{profile}.{key_path}") + } else { + key_path.to_string() + } +} + +pub(crate) fn build_model_selection_edits( + profile: Option<&str>, + model: &str, + effort: Option, +) -> Vec { + let effort_edit = effort.map_or_else( + || clear_config_value(profile_scoped_key_path(profile, "model_reasoning_effort")), + |effort| { + replace_config_value( + profile_scoped_key_path(profile, "model_reasoning_effort"), + serde_json::json!(effort.to_string()), + ) + }, + ); + vec![ + replace_config_value( + profile_scoped_key_path(profile, "model"), + serde_json::json!(model), + ), + effort_edit, + ] +} + +pub(crate) fn build_service_tier_selection_edits( + profile: Option<&str>, + service_tier: Option<&str>, +) -> Vec { + let service_tier_edit = service_tier.map_or_else( + || clear_config_value(profile_scoped_key_path(profile, "service_tier")), + |service_tier| { + let config_value = + match codex_protocol::config_types::ServiceTier::from_request_value(service_tier) { + Some(codex_protocol::config_types::ServiceTier::Fast) => "fast", + Some(codex_protocol::config_types::ServiceTier::Flex) => "flex", + None => service_tier, + }; + replace_config_value( + profile_scoped_key_path(profile, "service_tier"), + serde_json::json!(config_value), + ) + }, + ); + let mut edits = vec![service_tier_edit]; + if service_tier.is_none() { + edits.push(replace_config_value( + "notice.fast_default_opt_out", + serde_json::json!(true), + )); + } + edits +} + +pub(crate) async fn write_config_batch( + request_handle: AppServerRequestHandle, + edits: Vec, +) -> Result<()> { + let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4())); + let _: ConfigWriteResponse = request_handle + .request_typed(ClientRequest::ConfigBatchWrite { + request_id, + params: ConfigBatchWriteParams { + edits, + file_path: None, + expected_version: None, + reload_user_config: true, + }, + }) + .await + .wrap_err("config/batchWrite failed in TUI")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn profile_scoped_key_path_quotes_dotted_profile_names() { + assert_eq!( + profile_scoped_key_path(Some("team.prod"), "model"), + "profiles.\"team.prod\".model" + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 69b6bec0f3..871cdcc0dd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -112,6 +112,7 @@ mod clipboard_copy; mod clipboard_paste; mod collaboration_modes; mod color; +mod config_update; pub(crate) mod custom_terminal; mod pets; pub use custom_terminal::Terminal; From 4c897723142406baf3c5f6f8730f2844ef1aa436 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 05:49:28 +0300 Subject: [PATCH 040/114] sdk/python: add first-class login support (#23093) ## Why The Python SDK can already create threads and run turns, but authentication still has to be arranged outside the SDK. App-server already exposes account login, account inspection, logout, and `account/login/completed` notifications, so SDK users currently have to work around a missing public client layer for a core setup step. This change makes authentication a normal SDK workflow while preserving the backend flow shape: API-key login completes immediately, and interactive ChatGPT flows return live handles that complete later through app-server notifications. ## What changed - Added public sync and async auth methods on `Codex` / `AsyncCodex`: - `login_api_key(...)` - `login_chatgpt()` - `login_chatgpt_device_code()` - `account(...)` - `logout()` - Added public browser-login and device-code handle types with attempt-local `wait()` and `cancel()` helpers. Cancellation stays on the handle instead of a root-level SDK method. - Extended the Python app-server client and notification router so login completion events are routed by `login_id` without consuming unrelated global notifications. - Kept login request/handle logic in a focused internal `_login.py` module so `api.py` remains the public facade instead of absorbing more auth plumbing. - Exported the new handle types plus curated account/login response types from the SDK surfaces. - Updated SDK docs, added sync/async login walkthrough examples, and added a notebook login walkthrough cell. ## Verification Added SDK coverage for: - API-key login, account readback, and logout through the app-server harness in both sync and async clients. - Browser login cancellation plus `handle.wait()` completion through the real app-server boundary used by the Python SDK harness. - Waiter routing that stays scoped across replaced interactive login attempts, plus async handle cancellation coverage. - Login notification demuxing, replay of early completion events, and async client delegation. - Public export/signature assertions. - Real integration-suite smoke coverage for the new examples and notebook login cell. --- sdk/python/README.md | 30 +++ sdk/python/docs/api-reference.md | 48 +++++ sdk/python/docs/faq.md | 11 +- sdk/python/docs/getting-started.md | 42 ++++- .../examples/15_login_and_account/async.py | 34 ++++ .../examples/15_login_and_account/sync.py | 26 +++ sdk/python/examples/README.md | 2 + sdk/python/notebooks/sdk_walkthrough.ipynb | 21 +++ sdk/python/src/openai_codex/__init__.py | 8 + sdk/python/src/openai_codex/_login.py | 172 ++++++++++++++++++ .../src/openai_codex/_message_router.py | 64 +++++++ sdk/python/src/openai_codex/api.py | 73 ++++++++ sdk/python/src/openai_codex/async_client.py | 48 +++++ sdk/python/src/openai_codex/client.py | 88 ++++++++- sdk/python/src/openai_codex/types.py | 10 + sdk/python/tests/app_server_harness.py | 5 +- sdk/python/tests/test_app_server_login.py | 90 +++++++++ .../tests/test_public_api_signatures.py | 9 + .../tests/test_real_app_server_integration.py | 4 +- 19 files changed, 772 insertions(+), 13 deletions(-) create mode 100644 sdk/python/examples/15_login_and_account/async.py create mode 100644 sdk/python/examples/15_login_and_account/sync.py create mode 100644 sdk/python/src/openai_codex/_login.py create mode 100644 sdk/python/tests/test_app_server_login.py diff --git a/sdk/python/README.md b/sdk/python/README.md index d3aeff7ac5..a04d59b239 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -26,6 +26,8 @@ when you intentionally want to run against a specific local app-server binary. from openai_codex import Codex with Codex() as codex: + # Call login_api_key(...) first when this app-server session is not + # already authenticated. thread = codex.thread_start(model="gpt-5") result = thread.run("Say hello in one sentence.") print(result.final_response) @@ -35,6 +37,34 @@ with Codex() as codex: `result.final_response` is `None` when the turn completes without a final-answer or phase-less assistant message item. +## Login + +Use the auth helper that matches your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT login returns a handle. Open the provided URL or device-code +page, then wait for the matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Use `login_chatgpt_device_code()` for device-code auth, `handle.cancel()` to +stop an in-progress interactive login, and `logout()` to clear the active +app-server account session. + ## Docs map - Golden path tutorial: `docs/getting-started.md` diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 6be7e19737..1b885b8c89 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -12,6 +12,10 @@ from openai_codex import ( Codex, AsyncCodex, ApprovalMode, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, RunResult, Thread, AsyncThread, @@ -26,6 +30,11 @@ from openai_codex import ( MentionInput, ) from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, InitializeResponse, ThreadItem, ThreadTokenUsage, @@ -47,6 +56,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> None` +- `login_api_key(api_key: str) -> None` +- `login_chatgpt() -> ChatgptLoginHandle` +- `login_chatgpt_device_code() -> DeviceCodeLoginHandle` +- `account(*, refresh_token: bool = False) -> GetAccountResponse` +- `logout() -> None` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` @@ -82,6 +96,11 @@ Properties/methods: - `metadata -> InitializeResponse` - `close() -> Awaitable[None]` +- `login_api_key(api_key: str) -> Awaitable[None]` +- `login_chatgpt() -> Awaitable[AsyncChatgptLoginHandle]` +- `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]` +- `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]` +- `logout() -> Awaitable[None]` - `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]` - `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` @@ -97,6 +116,30 @@ async with AsyncCodex() as codex: ... ``` +## Login handles + +### ChatgptLoginHandle / AsyncChatgptLoginHandle + +- `login_id: str` +- `auth_url: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +### DeviceCodeLoginHandle / AsyncDeviceCodeLoginHandle + +- `login_id: str` +- `verification_url: str` +- `user_code: str` +- `wait() -> AccountLoginCompletedNotification` +- `cancel() -> CancelLoginAccountResponse` + +Async handle methods return awaitables. + +`wait()` consumes only the completion notification for its matching login +attempt. API-key login completes synchronously and does not return a handle. + ## Thread / AsyncThread `Thread` and `AsyncThread` share the same shape and intent. @@ -176,6 +219,11 @@ The SDK wrappers return and accept public app-server models wherever possible: ```python from openai_codex.types import ( + Account, + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ThreadReadResponse, Turn, TurnStatus, diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 1a87b50a14..d1732e74aa 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -23,6 +23,16 @@ Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout If your app is not already async, stay with `Codex`. +## How do I log in? + +- `login_api_key(...)` authenticates immediately with an API key. +- `login_chatgpt()` starts browser login and returns a handle with `auth_url`. +- `login_chatgpt_device_code()` starts device-code login and returns a handle + with `verification_url` and `user_code`. +- Interactive handles expose `wait()` for the matching + `account/login/completed` notification and `cancel()` to stop that attempt. +- `account()` reads the current account state, and `logout()` clears it. + ## Public kwargs are snake_case Public API keyword names are snake_case. The SDK still maps them to wire camelCase under the hood. @@ -56,7 +66,6 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- local auth/session is missing - incompatible/old app-server ## Why does a turn "hang"? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 6e37314e77..b362a90347 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -19,9 +19,37 @@ Requirements: - Python `>=3.10` - uv - installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override -- local Codex auth/session configured -## 2) Run your first turn (sync) +## 2) Authenticate when needed + +Existing Codex auth state is reused automatically. To authenticate from the SDK, +use the flow that fits your app: + +```python +from openai_codex import Codex + +with Codex() as codex: + codex.login_api_key("sk-...") + account = codex.account() + print(account.account) +``` + +Interactive ChatGPT browser login returns a handle that carries the URL and the +matching completion event: + +```python +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + completed = login.wait() + print(completed.success) +``` + +Device-code login works the same way with +`login_chatgpt_device_code()`, which exposes `verification_url`, `user_code`, +and `wait()`. + +## 3) Run your first turn (sync) ```python from openai_codex import Codex @@ -47,7 +75,7 @@ What happened: - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status - one client can consume multiple active turns concurrently; turn streams are routed by turn ID -## 3) Continue the same thread (multi-turn) +## 4) Continue the same thread (multi-turn) ```python from openai_codex import Codex @@ -62,7 +90,7 @@ with Codex() as codex: print("second:", second.final_response) ``` -## 4) Async parity +## 5) Async parity Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex` initializes lazily, and context entry makes startup/shutdown explicit. @@ -82,7 +110,7 @@ async def main() -> None: asyncio.run(main()) ``` -## 5) Resume an existing thread +## 6) Resume an existing thread ```python from openai_codex import Codex @@ -95,7 +123,7 @@ with Codex() as codex: print(result.final_response) ``` -## 6) Public app-server types +## 7) Public app-server types The convenience wrappers live at the package root. Public app-server value and event types live under: @@ -104,7 +132,7 @@ event types live under: from openai_codex.types import ThreadReadResponse, Turn, TurnStatus ``` -## 7) Next stops +## 8) Next stops - API surface and signatures: `docs/api-reference.md` - Common decisions/pitfalls: `docs/faq.md` diff --git a/sdk/python/examples/15_login_and_account/async.py b/sdk/python/examples/15_login_and_account/async.py new file mode 100644 index 0000000000..7de6c2afa5 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/async.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +import asyncio + +from openai_codex import AsyncCodex + + +async def main() -> None: + async with AsyncCodex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and await `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = await codex.login_chatgpt() + canceled = await login.cancel() + completed = await login.wait() + account = await codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/examples/15_login_and_account/sync.py b/sdk/python/examples/15_login_and_account/sync.py new file mode 100644 index 0000000000..ed20531186 --- /dev/null +++ b/sdk/python/examples/15_login_and_account/sync.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +_EXAMPLES_ROOT = Path(__file__).resolve().parents[1] +if str(_EXAMPLES_ROOT) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_ROOT)) + +from _bootstrap import ensure_local_sdk_src, runtime_config + +ensure_local_sdk_src() + +from openai_codex import Codex + +with Codex(config=runtime_config()) as codex: + # Browser login returns a live handle. Open `auth_url` and call `wait()` + # in a real app; this example cancels immediately so it stays non-blocking. + login = codex.login_chatgpt() + canceled = login.cancel() + completed = login.wait() + account = codex.account() + + print("login.id:", login.login_id) + print("login.auth_url:", login.auth_url) + print("login.cancel.status:", canceled.status) + print("login.completed.success:", completed.success) + print("account.requires_openai_auth:", account.requires_openai_auth) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index fe5b630898..569a51f477 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -82,3 +82,5 @@ python examples/01_quickstart_constructor/async.py - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage - `14_turn_controls/` - separate best-effort `steer()` and `interrupt()` demos with concise summaries +- `15_login_and_account/` + - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 62480fe18a..e51c217dbd 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -140,6 +140,27 @@ ")\n" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Cell 2b: browser login handle lifecycle\n", + "with Codex() as codex:\n", + " # Open this URL and call `wait()` without canceling when completing login for real.\n", + " login = codex.login_chatgpt()\n", + " canceled = login.cancel()\n", + " completed = login.wait()\n", + " account = codex.account()\n", + "\n", + " print('login.id:', login.login_id)\n", + " print('login.auth_url:', login.auth_url)\n", + " print('login.cancel.status:', canceled.status)\n", + " print('login.completed.success:', completed.success)\n", + " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 4a68fabbef..8c8dcc3eb2 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -1,10 +1,14 @@ from ._version import __version__ from .api import ( ApprovalMode, + AsyncChatgptLoginHandle, AsyncCodex, + AsyncDeviceCodeLoginHandle, AsyncThread, AsyncTurnHandle, + ChatgptLoginHandle, Codex, + DeviceCodeLoginHandle, ImageInput, Input, InputItem, @@ -39,6 +43,10 @@ __all__ = [ "Codex", "AsyncCodex", "ApprovalMode", + "ChatgptLoginHandle", + "DeviceCodeLoginHandle", + "AsyncChatgptLoginHandle", + "AsyncDeviceCodeLoginHandle", "Thread", "AsyncThread", "TurnHandle", diff --git a/sdk/python/src/openai_codex/_login.py b/sdk/python/src/openai_codex/_login.py new file mode 100644 index 0000000000..cdfdda41da --- /dev/null +++ b/sdk/python/src/openai_codex/_login.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .async_client import AsyncAppServerClient +from .client import AppServerClient +from .generated.v2_all import ( + AccountLoginCompletedNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountParams, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountParams, + ChatgptLoginAccountResponse, + LoginAccountParams, +) + + +class _AsyncLoginOwner(Protocol): + """Subset of AsyncCodex needed by async login handles.""" + + _client: AsyncAppServerClient + + async def _ensure_initialized(self) -> None: + """Ensure the owning SDK client has a live app-server connection.""" + ... + + +def start_chatgpt_login(client: AppServerClient) -> ChatgptLoginHandle: + """Start browser ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return ChatgptLoginHandle( + client, + response_root.login_id, + response_root.auth_url, + ) + + +async def async_start_chatgpt_login(owner: _AsyncLoginOwner) -> AsyncChatgptLoginHandle: + """Start async browser ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptLoginAccountParams(type="chatgpt"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptLoginAccountResponse): + raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}") + return AsyncChatgptLoginHandle( + owner, + response_root.login_id, + response_root.auth_url, + ) + + +def start_device_code_login(client: AppServerClient) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return the handle for that attempt.""" + response = client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return DeviceCodeLoginHandle( + client, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +async def async_start_device_code_login( + owner: _AsyncLoginOwner, +) -> AsyncDeviceCodeLoginHandle: + """Start async device-code ChatGPT login and return that attempt's handle.""" + response = await owner._client.account_login_start( + LoginAccountParams( + root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"), + ) + ) + response_root = response.root + if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse): + raise RuntimeError(f"unexpected device-code login response: {response_root!r}") + return AsyncDeviceCodeLoginHandle( + owner, + response_root.login_id, + response_root.verification_url, + response_root.user_code, + ) + + +@dataclass(slots=True) +class ChatgptLoginHandle: + """Live browser-login attempt returned by `Codex.login_chatgpt()`.""" + + _client: AppServerClient + login_id: str + auth_url: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class DeviceCodeLoginHandle: + """Live device-code login attempt returned by `Codex.login_chatgpt_device_code()`.""" + + _client: AppServerClient + login_id: str + verification_url: str + user_code: str + + def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + return self._client.wait_for_login_completed(self.login_id) + + def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + return self._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncChatgptLoginHandle: + """Live browser-login attempt returned by `AsyncCodex.login_chatgpt()`.""" + + _codex: _AsyncLoginOwner + login_id: str + auth_url: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this browser login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this browser login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) + + +@dataclass(slots=True) +class AsyncDeviceCodeLoginHandle: + """Live device-code attempt returned by `AsyncCodex.login_chatgpt_device_code()`.""" + + _codex: _AsyncLoginOwner + login_id: str + verification_url: str + user_code: str + + async def wait(self) -> AccountLoginCompletedNotification: + """Wait for this device-code login attempt's completion notification.""" + await self._codex._ensure_initialized() + return await self._codex._client.wait_for_login_completed(self.login_id) + + async def cancel(self) -> CancelLoginAccountResponse: + """Cancel this device-code login attempt.""" + await self._codex._ensure_initialized() + return await self._codex._client.account_login_cancel(self.login_id) diff --git a/sdk/python/src/openai_codex/_message_router.py b/sdk/python/src/openai_codex/_message_router.py index 7d4554bb8a..14a01336a4 100644 --- a/sdk/python/src/openai_codex/_message_router.py +++ b/sdk/python/src/openai_codex/_message_router.py @@ -6,6 +6,7 @@ from collections import deque from .errors import AppServerError, map_jsonrpc_error from .generated.notification_registry import notification_turn_id +from .generated.v2_all import AccountLoginCompletedNotification from .models import JsonValue, Notification, UnknownNotification ResponseQueueItem = JsonValue | BaseException @@ -25,6 +26,8 @@ class MessageRouter: """Create empty response, turn, and global notification queues.""" self._lock = threading.Lock() self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {} + self._login_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} + self._pending_login_notifications: dict[str, deque[Notification]] = {} self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {} self._pending_turn_notifications: dict[str, deque[Notification]] = {} self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue() @@ -51,6 +54,36 @@ class MessageRouter: raise item return item + def register_login(self, login_id: str) -> None: + """Register a queue for one interactive login attempt.""" + + login_queue: queue.Queue[NotificationQueueItem] = queue.Queue() + with self._lock: + if login_id in self._login_notifications: + return + pending = self._pending_login_notifications.pop(login_id, deque()) + self._login_notifications[login_id] = login_queue + for notification in pending: + login_queue.put(notification) + + def unregister_login(self, login_id: str) -> None: + """Stop routing future notifications for one login attempt.""" + + with self._lock: + self._login_notifications.pop(login_id, None) + + def next_login_notification(self, login_id: str) -> Notification: + """Block until the next notification for a registered login attempt.""" + + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + raise RuntimeError(f"login {login_id!r} is not registered for waiting") + item = login_queue.get() + if isinstance(item, BaseException): + raise item + return item + def register_turn(self, turn_id: str) -> None: """Register a queue for a turn stream and replay early events.""" @@ -111,6 +144,18 @@ class MessageRouter: def route_notification(self, notification: Notification) -> None: """Deliver a notification to a turn queue or the global queue.""" + login_id = self._notification_login_id(notification) + if login_id is not None: + with self._lock: + login_queue = self._login_notifications.get(login_id) + if login_queue is None: + self._pending_login_notifications.setdefault(login_id, deque()).append( + notification + ) + return + login_queue.put(notification) + return + turn_id = self._notification_turn_id(notification) if turn_id is None: self._global_notifications.put(notification) @@ -132,16 +177,35 @@ class MessageRouter: with self._lock: response_waiters = list(self._response_waiters.values()) self._response_waiters.clear() + login_queues = list(self._login_notifications.values()) + self._login_notifications.clear() + self._pending_login_notifications.clear() turn_queues = list(self._turn_notifications.values()) self._pending_turn_notifications.clear() # Put the same transport failure into every queue so no SDK call blocks # forever waiting for a response that cannot arrive. for waiter in response_waiters: waiter.put(exc) + for login_queue in login_queues: + login_queue.put(exc) for turn_queue in turn_queues: turn_queue.put(exc) self._global_notifications.put(exc) + def _notification_login_id(self, notification: Notification) -> str | None: + """Extract the login attempt id from completion notifications.""" + if notification.method != "account/login/completed": + return None + + payload = notification.payload + if isinstance(payload, AccountLoginCompletedNotification): + return payload.login_id + if isinstance(payload, UnknownNotification): + raw_login_id = payload.params.get("loginId") + if isinstance(raw_login_id, str): + return raw_login_id + return None + def _notification_turn_id(self, notification: Notification) -> str | None: """Extract routing ids from known generated payloads or raw unknown payloads.""" payload = notification.payload diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 0ea99f9b3d..fe9b5bc67f 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -22,6 +22,16 @@ from ._inputs import ( _normalize_run_input, _to_wire_input, ) +from ._login import ( + AsyncChatgptLoginHandle, + AsyncDeviceCodeLoginHandle, + ChatgptLoginHandle, + DeviceCodeLoginHandle, + async_start_chatgpt_login, + async_start_device_code_login, + start_chatgpt_login, + start_device_code_login, +) from ._run import ( RunResult, _collect_async_run_result, @@ -30,6 +40,10 @@ from ._run import ( from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + ApiKeyLoginAccountParams, + GetAccountParams, + GetAccountResponse, + LoginAccountParams, ModelListResponse, Personality, ReasoningEffort, @@ -85,6 +99,33 @@ class Codex: def close(self) -> None: self._client.close() + def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + def login_chatgpt(self) -> ChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + return start_chatgpt_login(self._client) + + def login_chatgpt_device_code(self) -> DeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + return start_device_code_login(self._client) + + def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + return self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + def logout(self) -> None: + """Clear the current app-server account session.""" + self._client.account_logout() + # BEGIN GENERATED: Codex.flat_methods def thread_start( self, @@ -286,6 +327,38 @@ class AsyncCodex: self._init = None self._initialized = False + async def login_api_key(self, api_key: str) -> None: + """Authenticate app-server with an API key.""" + await self._ensure_initialized() + await self._client.account_login_start( + LoginAccountParams( + root=ApiKeyLoginAccountParams( + api_key=api_key, + type="apiKey", + ) + ) + ) + + async def login_chatgpt(self) -> AsyncChatgptLoginHandle: + """Start browser-based ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_chatgpt_login(self) + + async def login_chatgpt_device_code(self) -> AsyncDeviceCodeLoginHandle: + """Start device-code ChatGPT login and return its live handle.""" + await self._ensure_initialized() + return await async_start_device_code_login(self) + + async def account(self, *, refresh_token: bool = False) -> GetAccountResponse: + """Read the current app-server account state.""" + await self._ensure_initialized() + return await self._client.account_read(GetAccountParams(refresh_token=refresh_token)) + + async def logout(self) -> None: + """Clear the current app-server account session.""" + await self._ensure_initialized() + await self._client.account_logout() + # BEGIN GENERATED: AsyncCodex.flat_methods async def thread_start( self, diff --git a/sdk/python/src/openai_codex/async_client.py b/sdk/python/src/openai_codex/async_client.py index 2c38a43087..282154b0cc 100644 --- a/sdk/python/src/openai_codex/async_client.py +++ b/sdk/python/src/openai_codex/async_client.py @@ -8,7 +8,14 @@ from pydantic import BaseModel from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -88,6 +95,14 @@ class AsyncAppServerClient: """Register a turn notification queue on the wrapped sync client.""" self._sync.register_turn_notifications(turn_id) + def register_login_notifications(self, login_id: str) -> None: + """Register a login notification queue on the wrapped sync client.""" + self._sync.register_login_notifications(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Unregister a login notification queue on the wrapped sync client.""" + self._sync.unregister_login_notifications(login_id) + def unregister_turn_notifications(self, turn_id: str) -> None: """Unregister a turn notification queue on the wrapped sync client.""" self._sync.unregister_turn_notifications(turn_id) @@ -107,6 +122,28 @@ class AsyncAppServerClient: response_model=response_model, ) + async def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + """Start one account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_start, params) + + async def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + """Cancel one active account login attempt through the wrapped sync client.""" + return await self._call_sync(self._sync.account_login_cancel, login_id) + + async def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + """Read current account state through the wrapped sync client.""" + return await self._call_sync(self._sync.account_read, params) + + async def account_logout(self) -> LogoutAccountResponse: + """Clear the active account session through the wrapped sync client.""" + return await self._call_sync(self._sync.account_logout) + async def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -211,10 +248,21 @@ class AsyncAppServerClient: """Wait for the next global notification without blocking the event loop.""" return await self._call_sync(self._sync.next_notification) + async def next_login_notification(self, login_id: str) -> Notification: + """Wait for the next notification routed to one login attempt.""" + return await self._call_sync(self._sync.next_login_notification, login_id) + async def next_turn_notification(self, turn_id: str) -> Notification: """Wait for the next notification routed to one turn.""" return await self._call_sync(self._sync.next_turn_notification, turn_id) + async def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Wait for the completion notification routed to one login attempt.""" + return await self._call_sync(self._sync.wait_for_login_completed, login_id) + async def wait_for_turn_completed(self, turn_id: str) -> TurnCompletedNotification: """Wait for the completion notification routed to one turn.""" return await self._call_sync(self._sync.wait_for_turn_completed, turn_id) diff --git a/sdk/python/src/openai_codex/client.py b/sdk/python/src/openai_codex/client.py index a4b1e4acaf..6f2e2b8aac 100644 --- a/sdk/python/src/openai_codex/client.py +++ b/sdk/python/src/openai_codex/client.py @@ -17,7 +17,16 @@ from ._version import __version__ as SDK_VERSION from .errors import AppServerError, TransportClosedError from .generated.notification_registry import NOTIFICATION_MODELS from .generated.v2_all import ( + AccountLoginCompletedNotification, AgentMessageDeltaNotification, + CancelLoginAccountResponse, + ChatgptDeviceCodeLoginAccountResponse, + ChatgptLoginAccountResponse, + GetAccountParams as V2GetAccountParams, + GetAccountResponse, + LoginAccountParams as V2LoginAccountParams, + LoginAccountResponse, + LogoutAccountResponse, ModelListResponse, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -59,6 +68,8 @@ def _params_dict( | V2ThreadListParams | V2ThreadForkParams | V2TurnStartParams + | V2GetAccountParams + | V2LoginAccountParams | JsonObject | None ), @@ -246,7 +257,10 @@ class AppServerClient: waiter = self._router.create_response_waiter(request_id) try: - self._write_message({"id": request_id, "method": method, "params": params or {}}) + message: JsonObject = {"id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) except BaseException: self._router.discard_response_waiter(request_id) raise @@ -258,12 +272,27 @@ class AppServerClient: def notify(self, method: str, params: JsonObject | None = None) -> None: """Send a JSON-RPC notification without waiting for a response.""" - self._write_message({"method": method, "params": params or {}}) + message: JsonObject = {"method": method} + if params is not None: + message["params"] = params + self._write_message(message) def next_notification(self) -> Notification: """Return the next notification that is not scoped to an active turn.""" return self._router.next_global_notification() + def register_login_notifications(self, login_id: str) -> None: + """Start routing notifications for one interactive login attempt.""" + self._router.register_login(login_id) + + def unregister_login_notifications(self, login_id: str) -> None: + """Stop routing notifications for one interactive login attempt.""" + self._router.unregister_login(login_id) + + def next_login_notification(self, login_id: str) -> Notification: + """Return the next routed notification for the requested login id.""" + return self._router.next_login_notification(login_id) + def register_turn_notifications(self, turn_id: str) -> None: """Start routing notifications for one turn into its dedicated queue.""" self._router.register_turn(turn_id) @@ -276,6 +305,43 @@ class AppServerClient: """Return the next routed notification for the requested turn id.""" return self._router.next_turn_notification(turn_id) + def account_login_start( + self, + params: V2LoginAccountParams | JsonObject, + ) -> LoginAccountResponse: + response = self.request( + "account/login/start", + _params_dict(params), + response_model=LoginAccountResponse, + ) + response_root = response.root + if isinstance( + response_root, + ChatgptLoginAccountResponse | ChatgptDeviceCodeLoginAccountResponse, + ): + self.register_login_notifications(response_root.login_id) + return response + + def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: + return self.request( + "account/login/cancel", + {"loginId": login_id}, + response_model=CancelLoginAccountResponse, + ) + + def account_read( + self, + params: V2GetAccountParams | JsonObject | None = None, + ) -> GetAccountResponse: + return self.request( + "account/read", + _params_dict(params), + response_model=GetAccountResponse, + ) + + def account_logout(self) -> LogoutAccountResponse: + return self.request("account/logout", None, response_model=LogoutAccountResponse) + def thread_start( self, params: V2ThreadStartParams | JsonObject | None = None ) -> ThreadStartResponse: @@ -417,6 +483,24 @@ class AppServerClient: finally: self.unregister_turn_notifications(turn_id) + def wait_for_login_completed( + self, + login_id: str, + ) -> AccountLoginCompletedNotification: + """Block until the matching interactive login attempt completes.""" + self.register_login_notifications(login_id) + try: + while True: + notification = self.next_login_notification(login_id) + if ( + notification.method == "account/login/completed" + and isinstance(notification.payload, AccountLoginCompletedNotification) + and notification.payload.login_id == login_id + ): + return notification.payload + finally: + self.unregister_login_notifications(login_id) + def stream_text( self, thread_id: str, diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 995daa68b9..26ffdbb3ef 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -3,8 +3,13 @@ from __future__ import annotations from .generated.v2_all import ( + Account, + AccountLoginCompletedNotification, ApprovalsReviewer, AskForApproval, + CancelLoginAccountResponse, + CancelLoginAccountStatus, + GetAccountResponse, ModelListResponse, Personality, PlanType, @@ -35,8 +40,13 @@ from .generated.v2_all import ( from .models import InitializeResponse, JsonObject, Notification __all__ = [ + "Account", + "AccountLoginCompletedNotification", "ApprovalsReviewer", "AskForApproval", + "CancelLoginAccountResponse", + "CancelLoginAccountStatus", + "GetAccountResponse", "InitializeResponse", "JsonObject", "ModelListResponse", diff --git a/sdk/python/tests/app_server_harness.py b/sdk/python/tests/app_server_harness.py index 26fa8cf7ef..63de28d3af 100644 --- a/sdk/python/tests/app_server_harness.py +++ b/sdk/python/tests/app_server_harness.py @@ -206,10 +206,11 @@ class MockResponsesServer: class AppServerHarness: """Test fixture that points a pinned runtime app-server at MockResponsesServer.""" - def __init__(self, tmp_path: Path) -> None: + def __init__(self, tmp_path: Path, *, requires_openai_auth: bool = False) -> None: self.tmp_path = tmp_path self.codex_home = tmp_path / "codex-home" self.workspace = tmp_path / "workspace" + self.requires_openai_auth = requires_openai_auth self.responses = MockResponsesServer() def __enter__(self) -> AppServerHarness: @@ -238,6 +239,7 @@ class AppServerHarness: def _write_config(self) -> None: """Write config.toml that routes model calls to the mock server.""" config_toml = self.codex_home / "config.toml" + requires_openai_auth = "requires_openai_auth = true\n" if self.requires_openai_auth else "" config_toml.write_text( f""" model = "mock-model" @@ -252,6 +254,7 @@ base_url = "{self.responses.url}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 +{requires_openai_auth} """.lstrip() ) diff --git a/sdk/python/tests/test_app_server_login.py b/sdk/python/tests/test_app_server_login.py new file mode 100644 index 0000000000..088310e5e8 --- /dev/null +++ b/sdk/python/tests/test_app_server_login.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import base64 +import json + +from app_server_harness import AppServerHarness + +from openai_codex import AppServerConfig, Codex +from openai_codex.generated.v2_all import ( + ChatgptAuthTokensLoginAccountParams, + LoginAccountParams, +) + + +def _app_server_config(harness: AppServerHarness) -> AppServerConfig: + """Build an isolated login config without inheriting ambient API-key auth.""" + config = harness.app_server_config() + config.env = {**(config.env or {}), "OPENAI_API_KEY": ""} + return config + + +def test_api_key_login_authenticates_follow_up_model_requests(tmp_path) -> None: + """API-key login should authorize the next Responses request with that key.""" + with AppServerHarness(tmp_path, requires_openai_auth=True) as harness: + harness.responses.enqueue_assistant_message("api key auth", response_id="api-key-auth") + + with Codex(config=_app_server_config(harness)) as codex: + codex.login_api_key("sk-sdk-login-test") + result = codex.thread_start().run("prove api key auth") + request = harness.responses.single_request() + + assert { + "final_response": result.final_response, + "authorization": request.header("authorization"), + } == { + "final_response": "api key auth", + "authorization": "Bearer sk-sdk-login-test", + } + + +def test_chatgpt_token_login_authenticates_follow_up_model_requests(tmp_path) -> None: + """ChatGPT token handoff should authorize later Responses requests with that token.""" + account_id = "workspace-sdk-chatgpt" + + def _encode(payload: dict[str, object]) -> str: + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + # App-server parses claims from the access token before persisting external ChatGPT auth. + header = _encode({"alg": "none", "typ": "JWT"}) + claims = _encode( + { + "email": "sdk-chatgpt@example.com", + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "pro", + }, + } + ) + access_token = f"{header}.{claims}.sig" + + with AppServerHarness(tmp_path, requires_openai_auth=True) as harness: + harness.responses.enqueue_assistant_message( + "chatgpt token auth", + response_id="chatgpt-token-auth", + ) + + with Codex(config=_app_server_config(harness)) as codex: + login = codex._client.account_login_start( + LoginAccountParams( + root=ChatgptAuthTokensLoginAccountParams( + access_token=access_token, + chatgpt_account_id=account_id, + chatgpt_plan_type="pro", + type="chatgptAuthTokens", + ) + ) + ) + result = codex.thread_start().run("prove chatgpt token auth") + request = harness.responses.single_request() + + assert { + "login_type": login.root.type, + "final_response": result.final_response, + "authorization": request.header("authorization"), + } == { + "login_type": "chatgptAuthTokens", + "final_response": "chatgpt token auth", + "authorization": f"Bearer {access_token}", + } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index f13fb35c08..04eb18e4cb 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -27,6 +27,10 @@ EXPECTED_ROOT_EXPORTS = [ "Codex", "AsyncCodex", "ApprovalMode", + "ChatgptLoginHandle", + "DeviceCodeLoginHandle", + "AsyncChatgptLoginHandle", + "AsyncDeviceCodeLoginHandle", "Thread", "AsyncThread", "TurnHandle", @@ -55,8 +59,13 @@ EXPECTED_ROOT_EXPORTS = [ ] EXPECTED_TYPES_EXPORTS = [ + "Account", + "AccountLoginCompletedNotification", "ApprovalsReviewer", "AskForApproval", + "CancelLoginAccountResponse", + "CancelLoginAccountStatus", + "GetAccountResponse", "InitializeResponse", "JsonObject", "ModelListResponse", diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 7fcadcf534..0e688ff3c6 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -434,7 +434,7 @@ def test_notebook_sync_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: [ _notebook_cell_source(1), _notebook_cell_source(2), - _notebook_cell_source(3), + _notebook_cell_source(4), ] ) result = _run_python(runtime_env, source, timeout_s=240) @@ -450,7 +450,7 @@ def test_notebook_advanced_cell_smoke(runtime_env: PreparedRuntimeEnv) -> None: [ _notebook_cell_source(1), _notebook_cell_source(2), - _notebook_cell_source(7), + _notebook_cell_source(8), ] ) result = _run_python(runtime_env, source, timeout_s=360) From f0166cadbb07c79c193b0c0bcdff4b52a836d111 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 06:17:22 -0700 Subject: [PATCH 041/114] [codex] Return TurnResult from Python turn handles (#23151) ## Why `TurnHandle.run()` returned the raw app-server `Turn`, whose live start/completed payloads do not include loaded `items`, so users saw empty `items` after starting a turn. That made the handle-based path behave differently from `Thread.run(...)`, and pushed examples toward persisted-thread reads plus helper extraction. This PR makes the run APIs standalone: starting a turn and running it returns collected turn data directly, or fails visibly when required stream events are missing. ## What Changed - Replaces the public `RunResult` export with `TurnResult`. - Adds turn metadata to `TurnResult`: `id`, `status`, `error`, `started_at`, `completed_at`, and `duration_ms`, alongside `final_response`, `items`, and `usage`. - Changes `TurnHandle.run()` and `AsyncTurnHandle.run()` to consume stream events with the same collector used by `Thread.run(...)`. - Exports `TurnError` from `openai_codex.types` for the new result shape. - Updates tests, examples, docs, and the walkthrough notebook to use `result.final_response` and `result.items` directly. - Removes persisted-thread helper paths and placeholder/skipped control flows from the public examples and notebook. ## Verification - `python3 -m py_compile ...` over changed SDK, example, and test Python files. - `python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))"` - `git diff --check` - `PYTHONPATH=sdk/python/src python3 -c ...` import/signature smoke for `TurnResult`, `TurnHandle.run`, and `AsyncTurnHandle.run`. --- sdk/python/README.md | 5 +- sdk/python/docs/api-reference.md | 19 +- sdk/python/docs/faq.md | 9 +- sdk/python/docs/getting-started.md | 4 +- sdk/python/examples/02_turn_run/async.py | 16 +- sdk/python/examples/02_turn_run/sync.py | 16 +- .../examples/03_turn_stream_events/async.py | 27 ++- .../examples/03_turn_stream_events/sync.py | 27 ++- .../examples/04_models_and_metadata/async.py | 2 +- .../examples/04_models_and_metadata/sync.py | 2 +- .../examples/05_existing_thread/async.py | 11 +- .../examples/05_existing_thread/sync.py | 11 +- .../06_thread_lifecycle_and_controls/async.py | 48 ++-- .../06_thread_lifecycle_and_controls/sync.py | 42 ++-- .../examples/07_image_and_text/async.py | 11 +- sdk/python/examples/07_image_and_text/sync.py | 11 +- .../examples/08_local_image_and_text/async.py | 6 +- .../examples/08_local_image_and_text/sync.py | 6 +- sdk/python/examples/09_async_parity/sync.py | 12 +- .../10_error_handling_and_retry/async.py | 14 +- .../10_error_handling_and_retry/sync.py | 14 +- sdk/python/examples/11_cli_mini_app/async.py | 31 +-- sdk/python/examples/11_cli_mini_app/sync.py | 31 +-- .../12_turn_params_kitchen_sink/async.py | 17 +- .../12_turn_params_kitchen_sink/sync.py | 17 +- .../13_model_select_and_turn_params/async.py | 44 ++-- .../13_model_select_and_turn_params/sync.py | 39 ++-- sdk/python/examples/14_turn_controls/async.py | 58 ++--- sdk/python/examples/14_turn_controls/sync.py | 56 ++--- sdk/python/examples/README.md | 2 +- sdk/python/examples/_bootstrap.py | 54 +---- sdk/python/notebooks/sdk_walkthrough.ipynb | 212 ++++++------------ sdk/python/src/openai_codex/__init__.py | 4 +- sdk/python/src/openai_codex/_run.py | 33 ++- sdk/python/src/openai_codex/api.py | 39 +--- sdk/python/src/openai_codex/types.py | 2 + sdk/python/tests/app_server_helpers.py | 2 +- sdk/python/tests/test_app_server_lifecycle.py | 12 +- sdk/python/tests/test_app_server_run.py | 22 +- sdk/python/tests/test_app_server_streaming.py | 9 +- .../tests/test_public_api_signatures.py | 43 +++- .../tests/test_real_app_server_integration.py | 36 ++- 42 files changed, 399 insertions(+), 677 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index a04d59b239..9107d15ba9 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -34,8 +34,9 @@ with Codex() as codex: print(len(result.items)) ``` -`result.final_response` is `None` when the turn completes without a final-answer -or phase-less assistant message item. +`thread.run(...)` and `thread.turn(...).run()` return `TurnResult`. Its +`final_response` is `None` when the turn completes without a final-answer or +phase-less assistant message item. ## Login diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 1b885b8c89..4bee871f8c 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -16,11 +16,11 @@ from openai_codex import ( DeviceCodeLoginHandle, AsyncChatgptLoginHandle, AsyncDeviceCodeLoginHandle, - RunResult, Thread, AsyncThread, TurnHandle, AsyncTurnHandle, + TurnResult, Input, InputItem, TextInput, @@ -38,6 +38,7 @@ from openai_codex.types import ( InitializeResponse, ThreadItem, ThreadTokenUsage, + TurnError, TurnStatus, ) ``` @@ -146,7 +147,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> RunResult` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` @@ -154,7 +155,7 @@ attempt. API-key login completes synchronously and does not return a handle. ### AsyncThread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[RunResult]` +- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` - `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` @@ -164,6 +165,12 @@ attempt. API-key login completes synchronously and does not return a handle. the turn, consumes notifications until completion, and returns a small result object with: +- `id: str` +- `status: TurnStatus` +- `error: TurnError | None` +- `started_at: int | None` +- `completed_at: int | None` +- `duration_ms: int | None` - `final_response: str | None` - `items: list[ThreadItem]` - `usage: ThreadTokenUsage | None` @@ -172,7 +179,7 @@ object with: phase-less assistant message item. Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, -`interrupt()`) or the public `Turn` model from `TurnHandle.run()`. +`interrupt()`) before collecting the turn result. ## TurnHandle / AsyncTurnHandle @@ -181,7 +188,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, - `steer(input: Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` -- `run() -> openai_codex.types.Turn` +- `run() -> TurnResult` Behavior notes: @@ -193,7 +200,7 @@ Behavior notes: - `steer(input: Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` -- `run() -> Awaitable[openai_codex.types.Turn]` +- `run() -> Awaitable[TurnResult]` Behavior notes: diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index d1732e74aa..5f2ed87ffd 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -8,7 +8,8 @@ ## `run()` vs `stream()` -- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `openai_codex.types`. +- `Thread.run(...)` starts a turn and returns `TurnResult`. +- `TurnHandle.run()` / `AsyncTurnHandle.run()` consumes events for an existing turn handle and returns the same `TurnResult` shape. - `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event. Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing. @@ -66,7 +67,7 @@ Common causes: - published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file -- incompatible/old app-server +- app-server version older than the SDK schema ## Why does a turn "hang"? @@ -79,11 +80,11 @@ A turn is complete only when `turn/completed` arrives for that turn ID. Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`). -Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead. +Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs or update the runtime/schema version instead. ## Common pitfalls - Starting a new thread for every prompt when you wanted continuity. - Forgetting to `close()` (or not using context managers). -- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model. +- Reading `Turn.items` from live start/completed payloads instead of using `TurnResult.items`. - Mixing SDK input classes with raw dicts incorrectly. diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index b362a90347..1df0026c68 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -70,9 +70,9 @@ What happened: - `Codex()` started and initialized `codex app-server`. - `thread_start(...)` created a thread. -- `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage. +- `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. -- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status +- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID ## 4) Continue the same thread (multi-turn) diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index 0f6ef94f91..debfad8d6e 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,19 +21,14 @@ async def main() -> None: ) turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index 18aa8c2f81..c42bffa1f2 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -19,16 +14,11 @@ from openai_codex import Codex, TextInput with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("thread_id:", thread.id) print("turn_id:", result.id) print("status:", result.status) if result.error is not None: print("error:", result.error) - print("text:", assistant_text_from_turn(persisted_turn)) - print( - "persisted.items.count:", - 0 if persisted_turn is None else len(persisted_turn.items or []), - ) + print("text:", result.final_response) + print("items.count:", len(result.items)) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index dcf57af4f2..f0d3f1427d 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -29,7 +24,8 @@ async def main() -> None: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] async for event in turn.stream(): event_count += 1 @@ -38,24 +34,27 @@ async def main() -> None: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index f96f5c6acd..5a76bcb70f 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -23,7 +18,8 @@ with Codex(config=runtime_config()) as codex: event_count = 0 saw_started = False saw_delta = False - completed_status = "unknown" + completed_status = None + completed_texts = [] for event in turn.stream(): event_count += 1 @@ -32,24 +28,27 @@ with Codex(config=runtime_config()) as codex: print("stream.started") continue if event.method == "item/agentMessage/delta": - delta = getattr(event.payload, "delta", "") + delta = event.payload.delta if delta: if not saw_delta: print("assistant> ", end="", flush=True) print(delta, end="", flush=True) saw_delta = True continue + if event.method == "item/completed": + root = event.payload.item.root + if root.type == "agentMessage": + completed_texts.append(root.text) + continue if event.method == "turn/completed": - completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + completed_status = event.payload.turn.status.value + if completed_status is None: + raise RuntimeError("stream ended without turn/completed") if saw_delta: print() else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id) - final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]" + final_text = "".join(completed_texts).strip() print("assistant>", final_text) print("stream.started.seen:", saw_started) diff --git a/sdk/python/examples/04_models_and_metadata/async.py b/sdk/python/examples/04_models_and_metadata/async.py index ba36e51a23..23ca58131f 100644 --- a/sdk/python/examples/04_models_and_metadata/async.py +++ b/sdk/python/examples/04_models_and_metadata/async.py @@ -19,7 +19,7 @@ async def main() -> None: print("server:", server_label(codex.metadata)) models = await codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) if __name__ == "__main__": diff --git a/sdk/python/examples/04_models_and_metadata/sync.py b/sdk/python/examples/04_models_and_metadata/sync.py index 849116941f..155e53571d 100644 --- a/sdk/python/examples/04_models_and_metadata/sync.py +++ b/sdk/python/examples/04_models_and_metadata/sync.py @@ -15,4 +15,4 @@ with Codex(config=runtime_config()) as codex: print("server:", server_label(codex.metadata)) models = codex.models() print("models.count:", len(models.data)) - print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]") + print("models:", ", ".join(model.id for model in models.data[:5])) diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index 4dc36531d8..df3946ac87 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -32,9 +27,7 @@ async def main() -> None: resumed = await codex.thread_resume(original.id) second_turn = await resumed.turn(TextInput("Continue with one more fact.")) second = await second_turn.run() - persisted = await resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index ca441c4223..a0f0d37e27 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -25,6 +20,4 @@ with Codex(config=runtime_config()) as codex: # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) second = resumed.turn(TextInput("Continue with one more fact.")).run() - persisted = resumed.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print(assistant_text_from_turn(persisted_turn)) + print(second.final_response) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index ad73a896ee..24afbde6c2 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -33,45 +33,31 @@ async def main() -> None: listing_archived = await codex.thread_list(limit=20, archived=True) unarchived = await codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = await codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = await codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = await ( + await resumed.turn(TextInput("Continue in one short sentence.")) + ).run() - forked_info = "n/a" - try: - forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = await ( - await forked.turn(TextInput("Take a different angle in one short sentence.")) - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = await ( + await forked.turn(TextInput("Take a different angle in one short sentence.")) + ).run() - compact_info = "sent" - try: - _ = await unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = await unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) if __name__ == "__main__": diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index d01f165235..0958002dd9 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -25,40 +25,24 @@ with Codex(config=runtime_config()) as codex: listing_archived = codex.thread_list(limit=20, archived=True) unarchived = codex.thread_unarchive(reopened.id) - resumed_info = "n/a" - try: - resumed = codex.thread_resume( - unarchived.id, - model="gpt-5.4", - config={"model_reasoning_effort": "high"}, - ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - resumed_info = f"{resumed_result.id} {resumed_result.status}" - except Exception as exc: - resumed_info = f"skipped({type(exc).__name__})" + resumed = codex.thread_resume( + unarchived.id, + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + ) + resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() - forked_info = "n/a" - try: - forked = codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = forked.turn( - TextInput("Take a different angle in one short sentence.") - ).run() - forked_info = f"{forked_result.id} {forked_result.status}" - except Exception as exc: - forked_info = f"skipped({type(exc).__name__})" + forked = codex.thread_fork(unarchived.id, model="gpt-5.4") + forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() - compact_info = "sent" - try: - _ = unarchived.compact() - except Exception as exc: - compact_info = f"skipped({type(exc).__name__})" + compact_result = unarchived.compact() print("Lifecycle OK:", thread.id) print("first:", first.id, first.status) print("second:", second.id, second.status) - print("read.turns:", len(reading.thread.turns or [])) + print("read.turns:", len(reading.thread.turns)) print("list.active:", len(listing_active.data)) print("list.archived:", len(listing_archived.data)) - print("resumed:", resumed_info) - print("forked:", forked_info) - print("compact:", compact_info) + print("resumed:", resumed_result.id, resumed_result.status) + print("forked:", forked_result.id, forked_result.status) + print("compact:", compact_result.model_dump(mode="json", by_alias=True)) diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index 41fe96a830..c3237e320e 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,11 +28,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index ca82e7cfdc..f7402f18c9 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,8 +21,6 @@ with Codex(config=runtime_config()) as codex: ImageInput(REMOTE_IMAGE_URL), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/08_local_image_and_text/async.py b/sdk/python/examples/08_local_image_and_text/async.py index c3abf4d2ee..292c0c3e3d 100644 --- a/sdk/python/examples/08_local_image_and_text/async.py +++ b/sdk/python/examples/08_local_image_and_text/async.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -36,11 +34,9 @@ async def main() -> None: ] ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) if __name__ == "__main__": diff --git a/sdk/python/examples/08_local_image_and_text/sync.py b/sdk/python/examples/08_local_image_and_text/sync.py index 45c5161cb8..ee80a181ff 100644 --- a/sdk/python/examples/08_local_image_and_text/sync.py +++ b/sdk/python/examples/08_local_image_and_text/sync.py @@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) from _bootstrap import ( - assistant_text_from_turn, ensure_local_sdk_src, - find_turn_by_id, runtime_config, temporary_sample_image_path, ) @@ -29,8 +27,6 @@ with temporary_sample_image_path() as image_path: LocalImageInput(str(image_path.resolve())), ] ).run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Status:", result.status) - print(assistant_text_from_turn(persisted_turn)) + print(result.final_response) diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 018e588524..4b5d567159 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -5,13 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, - server_label, -) +from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() @@ -23,9 +17,7 @@ with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) turn = thread.turn(TextInput("Say hello in one sentence.")) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) print("Thread:", thread.id) print("Turn:", result.id) - print("Text:", assistant_text_from_turn(persisted_turn).strip()) + print("Text:", result.final_response.strip()) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index 65c95b2b63..f1711bffc9 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -73,19 +68,16 @@ async def main() -> None: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") return except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") return - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) + return - print("Text:", assistant_text_from_turn(persisted_turn)) + print("Text:", result.final_response) def _run_turn(thread, prompt: str): diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index 24ee243db3..c134063c80 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,13 +30,10 @@ with Codex(config=runtime_config()) as codex: ) except ServerBusyError as exc: print("Server overloaded after retries:", exc.message) - print("Text:") except JsonRpcError as exc: print(f"JSON-RPC error {exc.code}: {exc.message}") - print("Text:") else: - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) if result.status == TurnStatus.failed: print("Turn failed:", result.error) - print("Text:", assistant_text_from_turn(persisted_turn)) + else: + print("Text:", result.final_response) diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index 1b0a2cfd3a..df9fc89b86 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -21,19 +21,9 @@ from openai_codex.types import ( ) -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -65,16 +55,14 @@ async def main() -> None: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) async for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -83,12 +71,13 @@ async def main() -> None: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index dcb801d29c..fd16f930dc 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -21,19 +21,9 @@ from openai_codex.types import ( print("Codex mini CLI. Type /exit to quit.") -def _status_value(status: object | None) -> str: - return str(getattr(status, "value", status)) - - -def _format_usage(usage: object | None) -> str: - if usage is None: - return "usage> (none)" - - last = getattr(usage, "last", None) - total = getattr(usage, "total", None) - if last is None or total is None: - return f"usage> {usage}" - +def _format_usage(usage: object) -> str: + last = usage.last + total = usage.total return ( "usage>\n" f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n" @@ -60,16 +50,14 @@ with Codex(config=runtime_config()) as codex: usage = None status = None error = None - printed_delta = False print("assistant> ", end="", flush=True) for event in turn.stream(): payload = event.payload if event.method == "item/agentMessage/delta": - delta = getattr(payload, "delta", "") + delta = payload.delta if delta: print(delta, end="", flush=True) - printed_delta = True continue if isinstance(payload, ThreadTokenUsageUpdatedNotification): usage = payload.token_usage @@ -78,12 +66,13 @@ with Codex(config=runtime_config()) as codex: status = payload.turn.status error = payload.turn.error - if printed_delta: - print() - else: - print("[no text]") + print() + if status is None: + raise RuntimeError("stream ended without turn/completed") + if usage is None: + raise RuntimeError("stream ended without token usage") - status_text = _status_value(status) + status_text = status.value print(f"assistant.status> {status_text}") if status_text == "failed": print("assistant.error>", error) diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 30b72f3173..258d674a88 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -60,9 +55,7 @@ async def main() -> None: summary=SUMMARY, ) result = await turn.run() - persisted = await thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -70,8 +63,8 @@ async def main() -> None: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -86,7 +79,7 @@ async def main() -> None: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index dffe74a915..feced36f20 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -54,9 +49,7 @@ with Codex(config=runtime_config()) as codex: summary=SUMMARY, ) result = turn.run() - persisted = thread.read(include_turns=True) - persisted_turn = find_turn_by_id(persisted.thread.turns, result.id) - structured_text = assistant_text_from_turn(persisted_turn).strip() + structured_text = result.final_response.strip() try: structured = json.loads(structured_text) except json.JSONDecodeError as exc: @@ -64,8 +57,8 @@ with Codex(config=runtime_config()) as codex: f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}" ) from exc - summary = structured.get("summary") - actions = structured.get("actions") + summary = structured["summary"] + actions = structured["actions"] if ( not isinstance(summary, str) or not isinstance(actions, list) @@ -80,4 +73,4 @@ with Codex(config=runtime_config()) as codex: print("actions:") for action in actions: print("-", action) - print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or [])) + print("Items:", len(result.items)) diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index f2810d8fd9..31a0c9c243 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -35,29 +30,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -103,13 +96,9 @@ async def main() -> None: effort=selected_effort, ) first = await first_turn.run() - persisted = await thread.read(include_turns=True) - first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_persisted_turn)) - print( - "items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or []) - ) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second_turn = await thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -122,14 +111,9 @@ async def main() -> None: summary=ReasoningSummary.model_validate("concise"), ) second = await second_turn.run() - persisted = await thread.read(include_turns=True) - second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_persisted_turn)) - print( - "items.params:", - 0 if second_persisted_turn is None else len(second_persisted_turn.items or []), - ) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) if __name__ == "__main__": diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index b1154ce171..9252f3913c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - find_turn_by_id, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -33,29 +28,27 @@ REASONING_RANK = { "high": 4, "xhigh": 5, } -PREFERRED_MODEL = "gpt-5.4" def _pick_highest_model(models): - visible = [m for m in models if not m.hidden] or models - preferred = next( - (m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None - ) - if preferred is not None: - return preferred + visible = [m for m in models if not m.hidden] + if not visible: + raise RuntimeError("models response did not include visible models") + known_names = {m.id for m in visible} | {m.model for m in visible} top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)] - pool = top_candidates or visible - return max(pool, key=lambda m: (m.model, m.id)) + if not top_candidates: + raise RuntimeError("models response did not include top-level visible models") + return max(top_candidates, key=lambda m: (m.model, m.id)) def _pick_highest_turn_effort(model) -> ReasoningEffort: if not model.supported_reasoning_efforts: - return ReasoningEffort.medium + raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts") best = max( model.supported_reasoning_efforts, - key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1), + key=lambda option: REASONING_RANK[option.reasoning_effort.value], ) return ReasoningEffort(best.reasoning_effort.value) @@ -99,11 +92,9 @@ with Codex(config=runtime_config()) as codex: model=selected_model.model, effort=selected_effort, ).run() - persisted = thread.read(include_turns=True) - first_turn = find_turn_by_id(persisted.thread.turns, first.id) - print("agent.message:", assistant_text_from_turn(first_turn)) - print("items:", 0 if first_turn is None else len(first_turn.items or [])) + print("agent.message:", first.final_response) + print("items:", len(first.items)) second = thread.turn( TextInput("Return JSON for a safe feature-flag rollout plan."), @@ -115,8 +106,6 @@ with Codex(config=runtime_config()) as codex: sandbox_policy=SANDBOX_POLICY, summary=ReasoningSummary.model_validate("concise"), ).run() - persisted = thread.read(include_turns=True) - second_turn = find_turn_by_id(persisted.thread.turns, second.id) - print("agent.message.params:", assistant_text_from_turn(second_turn)) - print("items.params:", 0 if second_turn is None else len(second_turn.items or [])) + print("agent.message.params:", second.final_response) + print("items.params:", len(second.items)) diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index 4f2777c2e4..f044dadbbe 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -26,56 +22,48 @@ async def main() -> None: steer_turn = await thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] async for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = ( - assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" - ) + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = await interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = await interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] async for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 03180ba8eb..6b6d046a43 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -5,11 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ( - assistant_text_from_turn, - ensure_local_sdk_src, - runtime_config, -) +from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() @@ -20,54 +16,48 @@ with Codex(config=runtime_config()) as codex: steer_turn = thread.turn( TextInput("Count from 1 to 40 with commas, then one summary sentence.") ) - steer_result = "sent" - try: - _ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) - except Exception as exc: - steer_result = f"skipped {type(exc).__name__}" + steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) steer_event_count = 0 - steer_completed_status = "unknown" - steer_completed_turn = None + steer_completed_status = None + steer_deltas = [] for event in steer_turn.stream(): steer_event_count += 1 + if event.method == "item/agentMessage/delta": + steer_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - steer_completed_turn = event.payload.turn - steer_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + steer_completed_status = event.payload.turn.status.value - steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]" + if steer_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + steer_preview = "".join(steer_deltas).strip() interrupt_turn = thread.turn( TextInput("Count from 1 to 200 with commas, then one summary sentence.") ) - interrupt_result = "sent" - try: - _ = interrupt_turn.interrupt() - except Exception as exc: - interrupt_result = f"skipped {type(exc).__name__}" + interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 - interrupt_completed_status = "unknown" - interrupt_completed_turn = None + interrupt_completed_status = None + interrupt_deltas = [] for event in interrupt_turn.stream(): interrupt_event_count += 1 + if event.method == "item/agentMessage/delta": + interrupt_deltas.append(event.payload.delta) + continue if event.method == "turn/completed": - interrupt_completed_turn = event.payload.turn - interrupt_completed_status = getattr( - event.payload.turn.status, "value", str(event.payload.turn.status) - ) + interrupt_completed_status = event.payload.turn.status.value - interrupt_preview = ( - assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]" - ) + if interrupt_completed_status is None: + raise RuntimeError("stream ended without turn/completed") + interrupt_preview = "".join(interrupt_deltas).strip() - print("steer.result:", steer_result) + print("steer.result:", steer_result.model_dump(mode="json", by_alias=True)) print("steer.final.status:", steer_completed_status) print("steer.events.count:", steer_event_count) print("steer.assistant.preview:", steer_preview) - print("interrupt.result:", interrupt_result) + print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True)) print("interrupt.final.status:", interrupt_completed_status) print("interrupt.events.count:", interrupt_event_count) print("interrupt.assistant.preview:", interrupt_preview) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 569a51f477..bef1a16c0b 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -81,6 +81,6 @@ python examples/01_quickstart_constructor/async.py - `13_model_select_and_turn_params/` - list models, pick highest model + highest supported reasoning effort, run turns, print message and usage - `14_turn_controls/` - - separate best-effort `steer()` and `interrupt()` demos with concise summaries + - separate `steer()` and `interrupt()` demos with concise summaries - `15_login_and_account/` - browser-login handle lifecycle, cancellation, and account inspection diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index af115119ac..92fe18e26a 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -6,7 +6,7 @@ import sys import tempfile import zlib from pathlib import Path -from typing import Iterable, Iterator +from typing import Any, Iterator _SDK_PYTHON_DIR = Path(__file__).resolve().parents[1] _SDK_PYTHON_STR = str(_SDK_PYTHON_DIR) @@ -103,53 +103,5 @@ def temporary_sample_image_path() -> Iterator[Path]: yield image_path -def server_label(metadata: object) -> str: - server = getattr(metadata, "serverInfo", None) - server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip() - server_version = ( - (getattr(server, "version", None) or "") if server is not None else "" - ).strip() - if server_name and server_version: - return f"{server_name} {server_version}" - - user_agent = ( - (getattr(metadata, "userAgent", None) or "") if metadata is not None else "" - ).strip() - return user_agent or "unknown" - - -def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None: - for turn in turns or []: - if getattr(turn, "id", None) == turn_id: - return turn - return None - - -def assistant_text_from_turn(turn: object | None) -> str: - if turn is None: - return "" - - chunks: list[str] = [] - for item in getattr(turn, "items", []) or []: - raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item - if not isinstance(raw_item, dict): - continue - - item_type = raw_item.get("type") - if item_type == "agentMessage": - text = raw_item.get("text") - if isinstance(text, str) and text: - chunks.append(text) - continue - - if item_type != "message" or raw_item.get("role") != "assistant": - continue - - for content in raw_item.get("content") or []: - if not isinstance(content, dict) or content.get("type") != "output_text": - continue - text = content.get("text") - if isinstance(text, str) and text: - chunks.append(text) - - return "".join(chunks) +def server_label(metadata: Any) -> str: + return f"{metadata.serverInfo.name} {metadata.serverInfo.version}" diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index e51c217dbd..1eb80b3a71 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -25,23 +25,10 @@ " f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n", " )\n", "\n", - "try:\n", - " _ = os.getcwd()\n", - "except FileNotFoundError:\n", - " os.chdir(str(Path.home()))\n", - "\n", - "\n", "def _is_sdk_python_dir(path: Path) -> bool:\n", " return (path / 'pyproject.toml').exists() and (path / 'src' / 'openai_codex').exists()\n", "\n", "\n", - "def _iter_home_fallback_candidates(home: Path):\n", - " # bounded depth scan under home to support launching notebooks from unrelated cwd values\n", - " patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n", - " for pattern in patterns:\n", - " yield from home.glob(pattern)\n", - "\n", - "\n", "def _find_sdk_python_dir(start: Path) -> Path | None:\n", " checked = set()\n", "\n", @@ -70,21 +57,6 @@ " if found is not None:\n", " return found\n", "\n", - " for entry in sys.path:\n", - " if not entry:\n", - " continue\n", - " entry_path = Path(entry).expanduser()\n", - " for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", - " home = Path.home()\n", - " for candidate in _iter_home_fallback_candidates(home):\n", - " found = _consider(candidate)\n", - " if found is not None:\n", - " return found\n", - "\n", " return None\n", "\n", "\n", @@ -129,7 +101,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n", + "from _bootstrap import server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -172,12 +144,10 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('server:', server_label(codex.metadata))\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n", + " print('items:', len(result.items))\n" ] }, { @@ -192,12 +162,9 @@ "\n", " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - "\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", - " print('second text:', assistant_text_from_turn(second_turn))\n" + " print('second text:', second.final_response)\n" ] }, { @@ -221,41 +188,27 @@ " listing_archived = codex.thread_list(limit=20, archived=True)\n", " unarchived = codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n" + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n" ] }, { @@ -299,11 +252,8 @@ " summary=summary,\n", " )\n", " result = turn.run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -332,17 +282,20 @@ "\n", "\n", "def pick_highest_model(models):\n", - " visible = [m for m in models if not m.hidden] or models\n", + " visible = [m for m in models if not m.hidden]\n", + " if not visible:\n", + " raise RuntimeError('models response did not include visible models')\n", " known_names = {m.id for m in visible} | {m.model for m in visible}\n", " top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n", - " pool = top_candidates or visible\n", - " return max(pool, key=lambda m: (m.model, m.id))\n", + " if not top_candidates:\n", + " raise RuntimeError('models response did not include top-level visible models')\n", + " return max(top_candidates, key=lambda m: (m.model, m.id))\n", "\n", "\n", "def pick_highest_turn_effort(model) -> ReasoningEffort:\n", " if not model.supported_reasoning_efforts:\n", - " return ReasoningEffort.medium\n", - " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n", + " raise RuntimeError(f'{model.model} did not advertise supported reasoning efforts')\n", + " best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank[opt.reasoning_effort.value])\n", " return ReasoningEffort(best.reasoning_effort.value)\n", "\n", "\n", @@ -372,10 +325,8 @@ " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n", - " print('agent.message:', assistant_text_from_turn(first_turn))\n", - " print('items:', 0 if first_turn is None else len(first_turn.items or []))\n", + " print('agent.message:', first.final_response)\n", + " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", @@ -387,10 +338,8 @@ " sandbox_policy=sandbox_policy,\n", " summary=ReasoningSummary.model_validate('concise'),\n", " ).run()\n", - " persisted = thread.read(include_turns=True)\n", - " second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n", - " print('agent.message.params:', assistant_text_from_turn(second_turn))\n", - " print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n" + " print('agent.message.params:', second.final_response)\n", + " print('items.params:', len(second.items))\n" ] }, { @@ -408,11 +357,8 @@ " TextInput('What do you see in this image? 3 bullets.'),\n", " ImageInput(remote_image_url),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -429,11 +375,8 @@ " TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n", " LocalImageInput(str(local_image_path.resolve())),\n", " ]).run()\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -452,11 +395,8 @@ " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", " )\n", - " persisted = thread.read(include_turns=True)\n", - " persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n", - "\n", " print('status:', result.status)\n", - " print(assistant_text_from_turn(persisted_turn))\n" + " print(result.final_response)\n" ] }, { @@ -484,41 +424,27 @@ " listing_archived = await codex.thread_list(limit=20, archived=True)\n", " unarchived = await codex.thread_unarchive(reopened.id)\n", "\n", - " resumed_info = 'n/a'\n", - " try:\n", - " resumed = await codex.thread_resume(\n", - " unarchived.id,\n", - " model='gpt-5.4',\n", - " config={'model_reasoning_effort': 'high'},\n", - " )\n", - " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", - " resumed_info = f'{resumed_result.id} {resumed_result.status}'\n", - " except Exception as e:\n", - " resumed_info = f'skipped({type(e).__name__})'\n", + " resumed = await codex.thread_resume(\n", + " unarchived.id,\n", + " model='gpt-5.4',\n", + " config={'model_reasoning_effort': 'high'},\n", + " )\n", + " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", "\n", - " forked_info = 'n/a'\n", - " try:\n", - " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", - " forked_info = f'{forked_result.id} {forked_result.status}'\n", - " except Exception as e:\n", - " forked_info = f'skipped({type(e).__name__})'\n", + " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", + " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", "\n", - " compact_info = 'sent'\n", - " try:\n", - " _ = await unarchived.compact()\n", - " except Exception as e:\n", - " compact_info = f'skipped({type(e).__name__})'\n", + " compact_result = await unarchived.compact()\n", "\n", " print('Lifecycle OK:', thread.id)\n", " print('first:', first.id, first.status)\n", " print('second:', second.id, second.status)\n", - " print('read.turns:', len(reading.thread.turns or []))\n", + " print('read.turns:', len(reading.thread.turns))\n", " print('list.active:', len(listing_active.data))\n", " print('list.archived:', len(listing_archived.data))\n", - " print('resumed:', resumed_info)\n", - " print('forked:', forked_info)\n", - " print('compact:', compact_info)\n", + " print('resumed:', resumed_result.id, resumed_result.status)\n", + " print('forked:', forked_result.id, forked_result.status)\n", + " print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n", "\n", "\n", "await async_lifecycle_demo()\n" @@ -530,7 +456,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 10: async turn controls (best effort steer + interrupt)\n", + "# Cell 10: async turn controls (steer + interrupt)\n", "import asyncio\n", "\n", "\n", @@ -539,46 +465,46 @@ " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", "\n", - " steer_result = 'sent'\n", - " try:\n", - " _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", - " except Exception as e:\n", - " steer_result = f'skipped {type(e).__name__}'\n", + " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", "\n", " steer_event_count = 0\n", - " steer_completed_status = 'unknown'\n", - " steer_completed_turn = None\n", + " steer_completed_status = None\n", + " steer_deltas = []\n", " async for event in steer_turn.stream():\n", " steer_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " steer_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " steer_completed_turn = event.payload.turn\n", - " steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " steer_completed_status = event.payload.turn.status.value\n", "\n", - " steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n", + " if steer_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " steer_preview = ''.join(steer_deltas).strip()\n", "\n", " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", - " interrupt_result = 'sent'\n", - " try:\n", - " _ = await interrupt_turn.interrupt()\n", - " except Exception as e:\n", - " interrupt_result = f'skipped {type(e).__name__}'\n", + " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", - " interrupt_completed_status = 'unknown'\n", - " interrupt_completed_turn = None\n", + " interrupt_completed_status = None\n", + " interrupt_deltas = []\n", " async for event in interrupt_turn.stream():\n", " interrupt_event_count += 1\n", + " if event.method == 'item/agentMessage/delta':\n", + " interrupt_deltas.append(event.payload.delta)\n", + " continue\n", " if event.method == 'turn/completed':\n", - " interrupt_completed_turn = event.payload.turn\n", - " interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n", + " interrupt_completed_status = event.payload.turn.status.value\n", "\n", - " interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n", + " if interrupt_completed_status is None:\n", + " raise RuntimeError('stream ended without turn/completed')\n", + " interrupt_preview = ''.join(interrupt_deltas).strip()\n", "\n", - " print('steer.result:', steer_result)\n", + " print('steer.result:', steer_result.model_dump(mode='json', by_alias=True))\n", " print('steer.final.status:', steer_completed_status)\n", " print('steer.events.count:', steer_event_count)\n", " print('steer.assistant.preview:', steer_preview)\n", - " print('interrupt.result:', interrupt_result)\n", + " print('interrupt.result:', interrupt_result.model_dump(mode='json', by_alias=True))\n", " print('interrupt.final.status:', interrupt_completed_status)\n", " print('interrupt.events.count:', interrupt_event_count)\n", " print('interrupt.assistant.preview:', interrupt_preview)\n", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 8c8dcc3eb2..b3db7e3175 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,11 +14,11 @@ from .api import ( InputItem, LocalImageInput, MentionInput, - RunResult, SkillInput, TextInput, Thread, TurnHandle, + TurnResult, ) from .client import AppServerConfig from .errors import ( @@ -51,7 +51,7 @@ __all__ = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", diff --git a/sdk/python/src/openai_codex/_run.py b/sdk/python/src/openai_codex/_run.py index 73ec362460..ac9fa5ed38 100644 --- a/sdk/python/src/openai_codex/_run.py +++ b/sdk/python/src/openai_codex/_run.py @@ -12,13 +12,20 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn as AppServerTurn, TurnCompletedNotification, + TurnError, TurnStatus, ) from .models import Notification @dataclass(slots=True) -class RunResult: +class TurnResult: + id: str + status: TurnStatus + error: TurnError | None + started_at: int | None + completed_at: int | None + duration_ms: int | None final_response: str | None items: list[ThreadItem] usage: ThreadTokenUsage | None @@ -56,7 +63,7 @@ def _raise_for_failed_turn(turn: AppServerTurn) -> None: raise RuntimeError(f"turn failed with status {turn.status.value}") -def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult: +def _collect_turn_result(stream: Iterator[Notification], *, turn_id: str) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -76,16 +83,23 @@ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunR raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, ) -async def _collect_async_run_result( +async def _collect_async_turn_result( stream: AsyncIterator[Notification], *, turn_id: str -) -> RunResult: +) -> TurnResult: completed: TurnCompletedNotification | None = None items: list[ThreadItem] = [] usage: ThreadTokenUsage | None = None @@ -105,7 +119,14 @@ async def _collect_async_run_result( raise RuntimeError("turn completed event not received") _raise_for_failed_turn(completed.turn) - return RunResult( + turn = completed.turn + return TurnResult( + id=turn.id, + status=turn.status, + error=turn.error, + started_at=turn.started_at, + completed_at=turn.completed_at, + duration_ms=turn.duration_ms, final_response=_final_assistant_response_from_items(items), items=items, usage=usage, diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index fe9b5bc67f..347f95eeae 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -33,9 +33,9 @@ from ._login import ( start_device_code_login, ) from ._run import ( - RunResult, - _collect_async_run_result, - _collect_run_result, + TurnResult, + _collect_async_turn_result, + _collect_turn_result, ) from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig @@ -65,7 +65,6 @@ from .generated.v2_all import ( ThreadSourceKind, ThreadStartParams, ThreadStartSource, - Turn as AppServerTurn, TurnCompletedNotification, TurnInterruptResponse, TurnStartParams, @@ -533,7 +532,7 @@ class Thread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -548,7 +547,7 @@ class Thread: ) stream = turn.stream() try: - return _collect_run_result(stream, turn_id=turn.id) + return _collect_turn_result(stream, turn_id=turn.id) finally: stream.close() @@ -616,7 +615,7 @@ class AsyncThread: sandbox_policy: SandboxPolicy | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, - ) -> RunResult: + ) -> TurnResult: turn = await self.turn( _normalize_run_input(input), approval_mode=approval_mode, @@ -631,7 +630,7 @@ class AsyncThread: ) stream = turn.stream() try: - return await _collect_async_run_result(stream, turn_id=turn.id) + return await _collect_async_turn_result(stream, turn_id=turn.id) finally: await stream.aclose() @@ -717,21 +716,13 @@ class TurnHandle: finally: self._client.unregister_turn_notifications(self.id) - def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + def run(self) -> TurnResult: stream = self.stream() try: - for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return _collect_turn_result(stream, turn_id=self.id) finally: stream.close() - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn - @dataclass(slots=True) class AsyncTurnHandle: @@ -768,17 +759,9 @@ class AsyncTurnHandle: finally: self._codex._client.unregister_turn_notifications(self.id) - async def run(self) -> AppServerTurn: - completed: TurnCompletedNotification | None = None + async def run(self) -> TurnResult: stream = self.stream() try: - async for event in stream: - payload = event.payload - if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id: - completed = payload + return await _collect_async_turn_result(stream, turn_id=self.id) finally: await stream.aclose() - - if completed is None: - raise RuntimeError("turn completed event not received") - return completed.turn diff --git a/sdk/python/src/openai_codex/types.py b/sdk/python/src/openai_codex/types.py index 26ffdbb3ef..f5bbbc86c3 100644 --- a/sdk/python/src/openai_codex/types.py +++ b/sdk/python/src/openai_codex/types.py @@ -33,6 +33,7 @@ from .generated.v2_all import ( ThreadTokenUsageUpdatedNotification, Turn, TurnCompletedNotification, + TurnError, TurnInterruptResponse, TurnStatus, TurnSteerResponse, @@ -73,6 +74,7 @@ __all__ = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", diff --git a/sdk/python/tests/app_server_helpers.py b/sdk/python/tests/app_server_helpers.py index 10db00901c..af96a02e80 100644 --- a/sdk/python/tests/app_server_helpers.py +++ b/sdk/python/tests/app_server_helpers.py @@ -111,7 +111,7 @@ def agent_message_texts(events: list[Notification]) -> list[str]: def agent_message_texts_from_items(items: Iterable[Any]) -> list[str]: - """Extract agent-message text from completed run result items.""" + """Extract agent-message text from completed turn result items.""" texts: list[str] = [] for item in items: root = item.root diff --git a/sdk/python/tests/test_app_server_lifecycle.py b/sdk/python/tests/test_app_server_lifecycle.py index 77ba269450..9636c708c2 100644 --- a/sdk/python/tests/test_app_server_lifecycle.py +++ b/sdk/python/tests/test_app_server_lifecycle.py @@ -157,7 +157,7 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - run_result = await thread.run("materialize async thread") + turn_result = await thread.run("materialize async thread") await thread.set_name("async lifecycle") named = await thread.read() resumed = await codex.thread_resume(thread.id) @@ -166,14 +166,14 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None: unarchived = await codex.thread_unarchive(thread.id) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "named_thread": named.thread.name, "resumed_id": resumed.id, "forked_is_distinct": forked.id != thread.id, "archive_response": archive_response.model_dump(by_alias=True, mode="json"), "unarchived_id": unarchived.id, } == { - "run_final_response": "async materialized", + "turn_final_response": "async materialized", "named_thread": "async lifecycle", "resumed_id": thread.id, "forked_is_distinct": True, @@ -253,19 +253,19 @@ def test_compact_rpc_hits_mock_responses(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - run_result = thread.run("create history") + turn_result = thread.run("create history") compact_response = thread.compact() requests = harness.responses.wait_for_requests(2) assert { - "run_final_response": run_result.final_response, + "turn_final_response": turn_result.final_response, "compact_response": compact_response.model_dump( by_alias=True, mode="json", ), "request_kinds": [request_kind(request.path) for request in requests], } == { - "run_final_response": "history", + "turn_final_response": "history", "compact_response": {}, "request_kinds": ["responses", "responses"], } diff --git a/sdk/python/tests/test_app_server_run.py b/sdk/python/tests/test_app_server_run.py index a0ac3eca16..3f741e6ec6 100644 --- a/sdk/python/tests/test_app_server_run.py +++ b/sdk/python/tests/test_app_server_run.py @@ -145,8 +145,8 @@ def test_async_thread_run_uses_mock_responses( asyncio.run(scenario()) -def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """RunResult should use the last unknown-phase agent message as final text.""" +def test_sync_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """TurnResult should use the last unknown-phase agent message as final text.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -171,8 +171,8 @@ def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None: } -def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: - """RunResult should preserve an empty final agent message instead of skipping it.""" +def test_sync_turn_result_preserves_empty_last_message(tmp_path) -> None: + """TurnResult should preserve an empty final agent message instead of skipping it.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -197,8 +197,8 @@ def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None: } -def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: - """RunResult final_response should stay unset when app-server marks only commentary.""" +def test_sync_turn_result_does_not_promote_commentary_only_to_final(tmp_path) -> None: + """TurnResult final_response should stay unset when app-server marks only commentary.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( @@ -226,8 +226,8 @@ def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> } -def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: - """Async RunResult should use the last unknown-phase agent message.""" +def test_async_turn_result_uses_last_unknown_phase_message(tmp_path) -> None: + """Async TurnResult should use the last unknown-phase agent message.""" async def scenario() -> None: """Run one async result-mapping case against a pinned app-server.""" @@ -263,10 +263,10 @@ def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None: asyncio.run(scenario()) -def test_async_run_result_does_not_promote_commentary_only_to_final( +def test_async_turn_result_does_not_promote_commentary_only_to_final( tmp_path, ) -> None: - """Async RunResult final_response should stay unset for commentary-only output.""" + """Async TurnResult final_response should stay unset for commentary-only output.""" async def scenario() -> None: """Run one async commentary mapping case against a pinned app-server.""" @@ -318,7 +318,7 @@ def test_thread_run_raises_when_real_app_server_reports_failed_turn(tmp_path) -> def test_final_answer_phase_survives_real_app_server_mapping(tmp_path) -> None: - """RunResult should use the final-answer item emitted by app-server.""" + """TurnResult should use the final-answer item emitted by app-server.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_sse( sse( diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index ae217adb6a..02d089f2f1 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -5,6 +5,7 @@ import asyncio from app_server_harness import AppServerHarness from app_server_helpers import ( agent_message_texts, + agent_message_texts_from_items, next_async_delta, next_sync_delta, streaming_response, @@ -48,7 +49,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: def test_turn_run_returns_completed_turn(tmp_path) -> None: - """TurnHandle.run should wait for the app-server completion notification.""" + """TurnHandle.run should collect output and completion metadata.""" with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message("turn complete", response_id="turn-run-1") @@ -60,11 +61,13 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: assert { "turn_id": completed.id, "status": completed.status, - "items": completed.items, + "agent_messages": agent_message_texts_from_items(completed.items), + "final_response": completed.final_response, } == { "turn_id": turn.id, "status": TurnStatus.completed, - "items": [], + "agent_messages": ["turn complete"], + "final_response": "turn complete", } diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 04eb18e4cb..c7a00d3897 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -14,9 +14,11 @@ from openai_codex import ( AppServerConfig, AsyncCodex, AsyncThread, + AsyncTurnHandle, Codex, - RunResult, Thread, + TurnHandle, + TurnResult, ) from openai_codex._initialize_metadata import validate_initialize_metadata from openai_codex.types import InitializeResponse @@ -35,7 +37,7 @@ EXPECTED_ROOT_EXPORTS = [ "AsyncThread", "TurnHandle", "AsyncTurnHandle", - "RunResult", + "TurnResult", "Input", "InputItem", "TextInput", @@ -92,6 +94,7 @@ EXPECTED_TYPES_EXPORTS = [ "ThreadTokenUsageUpdatedNotification", "Turn", "TurnCompletedNotification", + "TurnError", "TurnInterruptResponse", "TurnStatus", "TurnSteerResponse", @@ -128,9 +131,39 @@ def test_root_exports_app_server_config() -> None: assert AppServerConfig.__name__ == "AppServerConfig" -def test_root_exports_run_result() -> None: - """The root package should expose the common-case run result wrapper.""" - assert RunResult.__name__ == "RunResult" +def test_root_exports_turn_result() -> None: + """The root package should expose the collected turn result wrapper.""" + assert { + "name": TurnResult.__name__, + "fields": list(TurnResult.__dataclass_fields__), + } == { + "name": "TurnResult", + "fields": [ + "id", + "status", + "error", + "started_at", + "completed_at", + "duration_ms", + "final_response", + "items", + "usage", + ], + } + + +def test_turn_run_methods_return_turn_result() -> None: + """Both convenience and handle-based run APIs return the same result shape.""" + funcs = [ + Thread.run, + TurnHandle.run, + AsyncThread.run, + AsyncTurnHandle.run, + ] + + assert {fn: inspect.signature(fn).return_annotation for fn in funcs} == dict.fromkeys( + funcs, "TurnResult" + ) def test_root_exports_approval_mode() -> None: diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 0e688ff3c6..08f07c986c 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -135,7 +135,7 @@ def _run_python( ) -def _runtime_compatibility_hint( +def _runtime_schema_hint( runtime_env: PreparedRuntimeEnv, *, stdout: str, @@ -144,7 +144,7 @@ def _runtime_compatibility_hint( combined = f"{stdout}\n{stderr}" if "ThreadStartResponse" in combined and "approvalsReviewer" in combined: return ( - "\nCompatibility hint:\n" + "\nSchema hint:\n" f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload " "that is older than the current SDK schema and is missing " "`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching " @@ -165,7 +165,7 @@ def _run_json_python( "Python snippet failed.\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) return json.loads(result.stdout) @@ -242,17 +242,12 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No config={"model_reasoning_effort": "high"}, ) result = thread.turn(TextInput("hello")).run() - persisted = thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) """ ), @@ -261,8 +256,8 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> None: @@ -345,17 +340,12 @@ def test_real_async_thread_turn_usage_and_ids_smoke( config={"model_reasoning_effort": "high"}, ) result = await (await thread.turn(TextInput("say ok"))).run() - persisted = await thread.read(include_turns=True) - persisted_turn = next( - (turn for turn in persisted.thread.turns or [] if turn.id == result.id), - None, - ) print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, "status": result.status.value, - "items_count": len(result.items or []), - "persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []), + "items_count": len(result.items), + "final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()), })) asyncio.run(main()) @@ -366,8 +356,8 @@ def test_real_async_thread_turn_usage_and_ids_smoke( assert isinstance(data["thread_id"], str) and data["thread_id"].strip() assert isinstance(data["turn_id"], str) and data["turn_id"].strip() assert data["status"] == "completed" - assert isinstance(data["items_count"], int) - assert isinstance(data["persisted_items_count"], int) + assert data["items_count"] > 0 + assert data["final_response_is_text"] is True def test_real_async_thread_run_convenience_smoke( @@ -531,7 +521,7 @@ def test_real_examples_run_and_assert( f"Example failed: {folder}/{script}\n" f"STDOUT:\n{result.stdout}\n" f"STDERR:\n{result.stderr}" - f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" + f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}" ) out = result.stdout @@ -541,7 +531,7 @@ def test_real_examples_run_and_assert( assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out - assert "persisted.items.count:" in out + assert "items.count:" in out elif folder == "03_turn_stream_events": assert "stream.completed:" in out assert "assistant>" in out From 545ede569cff13d0aabc782ebd8cdaa6d5cb86ab Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 17 May 2026 15:27:43 +0200 Subject: [PATCH 042/114] Make multi-agent v2 tool namespace configurable (#23147) ## Summary - Add `features.multi_agent_v2.tool_namespace` with config/schema validation for Responses-compatible namespace values. - Thread the resolved namespace into `ToolsConfig` for normal turns and review turns. - Wrap MultiAgentV2 tool specs and registry names in the configured namespace when namespace tools are supported, while falling back to the plain tool names when they are not. ## Validation - `just fmt` - `just write-config-schema` - `cargo test -p codex-features multi_agent_v2_feature_config -- --nocapture` - `cargo test -p codex-core test_build_specs_multi_agent_v2 -- --nocapture` - `cargo test -p codex-core multi_agent_v2_config -- --nocapture` - `cargo test -p codex-core multi_agent_v2_rejects_invalid_tool_namespace -- --nocapture` - `cargo test -p codex-tools` - `git diff --check` --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 47 +++++++ codex-rs/core/src/config/mod.rs | 72 ++++++++++ codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 + codex-rs/core/src/tools/registry.rs | 4 - codex-rs/core/src/tools/spec_plan.rs | 133 +++++++++++++++--- .../core/src/tools/spec_plan_model_tests.rs | 64 +++++++++ codex-rs/core/src/tools/spec_plan_tests.rs | 88 ++++++++++++ codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 3 + codex-rs/tools/src/tool_config.rs | 10 ++ 12 files changed, 409 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index de3632d629..07f49a3cd6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1516,6 +1516,12 @@ "subagent_usage_hint_text": { "type": "string" }, + "tool_namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce7a36d93f..21d291b309 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10112,6 +10112,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -10142,6 +10143,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("agents") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10164,6 +10169,7 @@ usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" +tool_namespace = "base_agents" hide_spawn_agent_metadata = true non_code_mode_only = false @@ -10176,6 +10182,7 @@ usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" +tool_namespace = "profile_agents" hide_spawn_agent_metadata = false non_code_mode_only = true "#, @@ -10204,6 +10211,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("profile subagent hint") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("profile_agents") + ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10464,6 +10475,42 @@ default_wait_timeout_ms = 2500 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { + for (namespace, expected_message) in [ + ( + "bad namespace", + "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", + ), + ( + "functions", + "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[features.multi_agent_v2] +enabled = true +tool_namespace = "{namespace}" +"# + ), + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("invalid multi_agent_v2 tool namespace should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), expected_message); + } + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 18158671ec..71a55b98dc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -953,6 +953,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -969,6 +970,7 @@ impl Default for MultiAgentV2Config { usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: false, non_code_mode_only: false, } @@ -2178,6 +2180,11 @@ fn resolve_multi_agent_v2_config( .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) .cloned() .or(default.subagent_usage_hint_text); + let tool_namespace = profile + .and_then(|config| config.tool_namespace.as_ref()) + .or_else(|| base.and_then(|config| config.tool_namespace.as_ref())) + .cloned() + .or(default.tool_namespace); let hide_spawn_agent_metadata = profile .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) @@ -2196,6 +2203,7 @@ fn resolve_multi_agent_v2_config( usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2290,6 +2298,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } +fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { + const LABEL: &str = "features.multi_agent_v2.tool_namespace"; + const MAX_LEN: usize = 64; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + let Some(namespace) = namespace else { + return Ok(()); + }; + if namespace.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not be empty"), + )); + } + if namespace.trim() != namespace { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not have leading or trailing whitespace"), + )); + } + if !namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), + )); + } + if namespace.chars().count() > MAX_LEN { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must be at most {MAX_LEN} characters"), + )); + } + if namespace == "mcp" + || namespace.starts_with("mcp__") + || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} uses a reserved namespace: {namespace}"), + )); + } + + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2911,6 +2982,7 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } + validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { if agent_max_threads_from_config.is_some() { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 497cddd912..7850e8f8b6 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 92c7893d5e..8f0ce2d5fd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -220,6 +220,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( @@ -539,6 +540,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone()) .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cf0e00623..96b686a83a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -281,10 +281,6 @@ impl ToolRegistry { self.tools.get(name).map(Arc::clone) } - pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.tools.get(name).map(|tool| tool.exposure()) - } - #[cfg(test)] pub(crate) fn has_tool(&self, name: &ToolName) -> bool { self.tool(name).is_some() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index be5e6741d4..3f355d2509 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -2,6 +2,7 @@ use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::tools::code_mode::execute_spec::create_code_mode_tool; +use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; @@ -58,12 +59,14 @@ use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_tools::DiscoverableTool; +use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironmentMode; use codex_tools::ToolExecutor; use codex_tools::ToolName; +use codex_tools::ToolOutput; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; @@ -73,6 +76,8 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::warn; +const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; + #[derive(Clone, Copy)] struct ToolRegistryBuildParams<'a> { mcp_tools: Option<&'a [ToolInfo]>, @@ -127,22 +132,29 @@ fn build_model_visible_specs_and_registry( let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); for executor in &executors { - if !seen_tool_names.insert(executor.tool_name()) { + let tool_name = executor.tool_name(); + if !seen_tool_names.insert(tool_name.clone()) { continue; } - if executor.exposure().is_direct() + let exposure = executor.exposure(); + if exposure.is_direct() + && !is_hidden_by_code_mode_only(config, &tool_name, exposure) && let Some(spec) = executor.spec() { - specs.push(spec_for_model_request(config, executor.exposure(), spec)); + specs.push(spec_for_model_request(config, exposure, spec)); + } + } + for spec in hosted_specs { + if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct) + { + specs.push(spec); } } - specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(executors); let model_visible_specs = merge_into_namespaces(specs) .into_iter() .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) - .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); (model_visible_specs, registry) @@ -210,17 +222,14 @@ fn agent_type_description(config: &ToolsConfig, default_agent_type_description: fn is_hidden_by_code_mode_only( config: &ToolsConfig, - registry: &ToolRegistry, - spec: &ToolSpec, + tool_name: &ToolName, + exposure: ToolExposure, ) -> bool { - if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { - return false; - } - - let exposure = registry - .tool_exposure(&ToolName::plain(spec.name())) - .unwrap_or(ToolExposure::Direct); - exposure != ToolExposure::DirectModelOnly + config.code_mode_only_enabled + && exposure != ToolExposure::DirectModelOnly + && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( + tool_name, + )) } fn build_code_mode_executors( @@ -442,6 +451,10 @@ fn collect_tool_executors( } else { ToolExposure::Direct }; + let tool_namespace = config + .namespace_tools + .then_some(config.multi_agent_v2_tool_namespace.as_deref()) + .flatten(); let agent_type_description = agent_type_description(config, params.default_agent_type_description); executors.push(multi_agent_v2_handler( @@ -454,15 +467,33 @@ fn collect_tool_executors( max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, }), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + SendMessageHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + FollowupTaskHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); executors.push(multi_agent_v2_handler( WaitAgentHandlerV2::new(params.wait_agent_timeouts), exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + CloseAgentHandlerV2, + exposure, + tool_namespace, + )); + executors.push(multi_agent_v2_handler( + ListAgentsHandlerV2, + exposure, + tool_namespace, )); - executors.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); - executors.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); @@ -592,8 +623,70 @@ fn append_extension_tool_executors( fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, + namespace: Option<&str>, ) -> Arc { - override_tool_exposure(Arc::new(handler), exposure) + let handler: Arc = match namespace { + Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride { + handler: Arc::new(handler), + namespace: namespace.to_string(), + }), + None => Arc::new(handler), + }; + override_tool_exposure(handler, exposure) +} + +struct MultiAgentV2NamespaceOverride { + handler: Arc, + namespace: String, +} + +#[async_trait::async_trait] +impl ToolExecutor for MultiAgentV2NamespaceOverride { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name) + } + + fn spec(&self) -> Option { + match self.handler.spec()? { + ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace { + name: self.namespace.clone(), + description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + })), + spec => Some(spec), + } + } + + fn exposure(&self) -> ToolExposure { + self.handler.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, codex_tools::FunctionCallError> { + self.handler.handle(invocation).await + } +} + +impl CoreToolRuntime for MultiAgentV2NamespaceOverride { + fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn create_diff_consumer( + &self, + ) -> Option> { + self.handler.create_diff_consumer() + } } fn compare_code_mode_tools( diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index dcc1be30ed..86692e48df 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -1341,3 +1341,67 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { }; assert!(!spawn_agent.description.contains("exec tool declaration")); } + +#[tokio::test] +async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!(tool_names, vec!["exec", "wait", "agents"]); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + assert!( + !exec + .description + .contains("do not attempt to use any other tools directly") + ); + + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name); + assert!(!tool.description.contains("exec tool declaration")); + } +} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 333efe6f06..3442fbb5ff 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -577,6 +577,77 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { assert_lacks_tool_name(&tools, "resume_agent"); } +#[test] +fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["agents"]); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + assert_lacks_tool_name(&tools, tool_name); + assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name))); + assert!(!registry.has_tool(&ToolName::plain(tool_name))); + assert_namespace_contains_function(&tools, "agents", tool_name); + } +} + +#[test] +fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let mut tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); + tools_config.namespace_tools = false; + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]); + assert_lacks_tool_name(&tools, "agents"); + assert!(registry.has_tool(&ToolName::plain("spawn_agent"))); + assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent"))); +} + #[test] fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() { let model_info = model_info(); @@ -2657,6 +2728,23 @@ fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { .unwrap_or_else(|| panic!("expected tool {expected_name}")) } +fn assert_namespace_contains_function( + tools: &[ToolSpec], + expected_namespace: &str, + expected_name: &str, +) { + let namespace_tool = find_tool(tools, expected_namespace); + let ToolSpec::Namespace(namespace) = namespace_tool else { + panic!("expected namespace tool {expected_namespace}"); + }; + assert!( + namespace.tools.iter().any(|tool| { + matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name) + }), + "expected tool {expected_name} in namespace {expected_namespace}" + ); +} + fn assert_process_tool_environment_id( tools: &[ToolSpec], expected_name: &str, diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 2b69c2d04d..bfd14237d5 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] + pub tool_namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bd55ef8e7d..f4aa3982b9 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -528,6 +528,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -550,6 +551,7 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), + tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), })) @@ -588,6 +590,7 @@ usage_hint_enabled = false usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: None, non_code_mode_only: None, })) diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 6e3ce15526..5c1681d164 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -121,6 +121,7 @@ pub struct ToolsConfig { pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, + pub multi_agent_v2_tool_namespace: Option, pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, pub wait_agent_max_timeout_ms: Option, @@ -260,6 +261,7 @@ impl ToolsConfig { hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, + multi_agent_v2_tool_namespace: None, max_concurrent_threads_per_session: None, wait_agent_min_timeout_ms: None, wait_agent_max_timeout_ms: None, @@ -316,6 +318,14 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_tool_namespace( + mut self, + multi_agent_v2_tool_namespace: Option, + ) -> Self { + self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace; + self + } + pub fn with_multi_agent_v2_non_code_mode_only( mut self, multi_agent_v2_non_code_mode_only: bool, From 0a83353ca3f7889efbf322b74476c8ebba0a2d36 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 17 May 2026 08:39:41 -0700 Subject: [PATCH 043/114] test: reduce core sandbox policy test setup (#23036) ## Why `SandboxPolicy` is a legacy compatibility shape, but several core tests still used it for ordinary turn setup even when the runtime path now carries `PermissionProfile`. With the first cleanup PR merged, this follow-up trims more core test scaffolding so remaining `SandboxPolicy` matches are easier to classify as production compatibility, legacy-boundary coverage, or explicit conversion tests. ## What Changed - Updated apply-patch handler and runtime tests to pass `PermissionProfile` directly. - Changed sandboxing test helpers to build permission profiles without first creating `SandboxPolicy` values. - Converted request-permissions integration turns to pass `PermissionProfile` through the test helper, leaving legacy sandbox projection at the `Op::UserTurn` boundary. - Converted unified exec integration helpers and direct turn submissions to use `PermissionProfile` values instead of `SandboxPolicy` setup. - Removed now-unused `SandboxPolicy` imports from the touched core tests. ## Test Plan - `just fmt` - `cargo test -p codex-core --lib tools::sandboxing::tests` - `cargo test -p codex-core --lib tools::runtimes::apply_patch::tests` - `cargo test -p codex-core --lib tools::handlers::apply_patch::tests` - `cargo test -p codex-core --lib unified_exec::process_manager::tests` - `cargo test -p codex-core --test all request_permissions::` - `cargo test -p codex-core --test all unified_exec::` - `just fix -p codex-core` --- .../src/tools/handlers/apply_patch_tests.rs | 23 ++-- .../src/tools/runtimes/apply_patch_tests.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 2 - codex-rs/core/src/tools/sandboxing_tests.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 10 +- .../core/tests/suite/request_permissions.rs | 97 ++++++------- codex-rs/core/tests/suite/unified_exec.rs | 130 +++++++++--------- 7 files changed, 127 insertions(+), 154 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 99854d23ea..e9a118641a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -3,7 +3,6 @@ use codex_apply_patch::MaybeApplyPatchVerified; use codex_exec_server::LOCAL_FS; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; @@ -251,12 +250,11 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() std::fs::create_dir_all(&nested).expect("create nested dir"); let file_path = AbsolutePathBuf::try_from(nested.join("file.txt")) .expect("nested file path should be absolute"); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd); @@ -273,12 +271,11 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { let file_path = AbsolutePathBuf::try_from(outside.join("file.txt")) .expect("outside file path should be absolute"); let cwd_abs = cwd.abs(); - let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }); + let sandbox_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs); let expected_outside = diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index ff2c3d1e89..f4d8d461b2 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -7,7 +7,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -201,8 +200,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { additional_permissions: Some(additional_permissions.clone()), permissions_preapproved: false, }; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let file_system_policy = FileSystemSandboxPolicy::default(); let permissions = PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 656615d4d1..1ca589a5f9 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -19,8 +19,6 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; -#[cfg(test)] -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformError; diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 3989862ab5..99b43699a9 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -5,7 +5,6 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::NetworkAccess; use pretty_assertions::assert_eq; use serde_json::json; @@ -39,13 +38,10 @@ fn bash_permission_request_payload_includes_description_when_present() { #[test] fn external_sandbox_skips_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }; assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy), + &FileSystemSandboxPolicy::external_sandbox(), ), ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -56,11 +52,10 @@ fn external_sandbox_skips_exec_approval_on_request() { #[test] fn restricted_sandbox_requires_exec_approval_on_request() { - let sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!( default_exec_approval_requirement( AskForApproval::OnRequest, - &FileSystemSandboxPolicy::from(&sandbox_policy) + &FileSystemSandboxPolicy::default() ), ExecApprovalRequirement::NeedsApproval { reason: None, @@ -79,9 +74,8 @@ fn default_exec_approval_requirement_rejects_sandbox_prompt_when_granular_disabl mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, @@ -101,9 +95,8 @@ fn default_exec_approval_requirement_keeps_prompt_when_granular_allows_sandbox_a mcp_elicitations: false, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let requirement = - default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy)); + default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default()); assert_eq!( requirement, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c554a2a4e4..cd83ce7a9c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -71,16 +71,10 @@ fn exec_server_params_use_env_policy_overlay_contract() { .expect("current dir") .try_into() .expect("absolute path"); - let sandbox_policy = codex_protocol::protocol::SandboxPolicy::DangerFullAccess; let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(&sandbox_policy); + codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let permission_profile = codex_protocol::models::PermissionProfile::Disabled; let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 0463ea3e2b..cf021f29e4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -33,6 +32,7 @@ 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::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -184,9 +184,11 @@ async fn submit_turn( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + permission_profile: CorePermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.cwd.path()); test.codex .submit(Op::UserTurn { environments: None, @@ -199,7 +201,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -285,16 +287,7 @@ async fn expect_request_permissions_event( } } -fn workspace_write_excluding_tmp() -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } -} - -fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile { +fn workspace_write_excluding_tmp() -> CorePermissionProfile { CorePermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -330,7 +323,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -384,7 +377,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( approval.additional_permissions, @@ -428,7 +421,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio request_permissions: false, mcp_elicitations: true, }); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -476,7 +469,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio &test, "request permissions under granular.request_permissions = false", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -514,7 +507,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -578,7 +571,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, command).await; assert_eq!( @@ -618,7 +611,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -675,7 +668,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -721,7 +714,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = CorePermissionProfile::read_only(); let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { @@ -779,7 +772,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -823,8 +816,8 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -890,7 +883,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -930,8 +923,8 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -995,7 +988,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul ) .await; - submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?; + submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?; let approval = expect_exec_approval(&test, &command).await; assert_eq!( @@ -1038,8 +1031,8 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1110,7 +1103,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1165,8 +1158,8 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1228,7 +1221,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1286,8 +1279,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1345,7 +1338,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1401,8 +1394,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1458,7 +1451,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i &test, "write outside the workspace without inline permission feature", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1516,8 +1509,8 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1611,7 +1604,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() &test, "write outside the workspace", approval_policy, - sandbox_policy, + permission_profile, ) .await?; @@ -1683,8 +1676,8 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1733,7 +1726,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "request permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1778,7 +1771,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { &test, "try to reuse permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; wait_for_completion(&test).await; @@ -1799,8 +1792,8 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp_profile(); + let permission_profile = workspace_write_excluding_tmp(); + let permission_profile_for_config = workspace_write_excluding_tmp(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -1854,7 +1847,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "request session permissions for later use", approval_policy, - sandbox_policy.clone(), + permission_profile.clone(), ) .await?; @@ -1896,7 +1889,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { &test, "reuse session permissions in a later turn", approval_policy, - sandbox_policy, + permission_profile, ) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 92b8aa9047..6b962116af 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -15,7 +15,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; @@ -35,6 +34,7 @@ use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -185,9 +185,11 @@ async fn wait_for_raw_unified_exec_output( async fn submit_unified_exec_turn( test: &TestCodex, prompt: &str, - sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, ) -> Result<()> { let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex .submit(Op::UserTurn { @@ -201,7 +203,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, - permission_profile: None, + permission_profile, model: session_model, effort: None, summary: None, @@ -272,6 +274,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let codex = test.codex.clone(); let cwd = test.cwd_path().to_path_buf(); let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &cwd); codex .submit(Op::UserTurn { @@ -284,8 +288,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -403,7 +407,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit begin event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit begin event", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -467,7 +471,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { submit_unified_exec_turn( &test, "run relative workdir test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -532,7 +536,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run workdir test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run workdir test", PermissionProfile::Disabled).await?; let begin_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), @@ -609,7 +613,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit end event", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit end event", PermissionProfile::Disabled).await?; let end_event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -667,7 +671,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "emit delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "emit delta", PermissionProfile::Disabled).await?; let event = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), @@ -729,7 +733,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { submit_unified_exec_turn( &test, "exercise full unified exec lifecycle", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -872,7 +876,7 @@ async fn unified_exec_short_lived_network_denial_emits_failed_end_event() -> Res #[allow(clippy::expect_used)] async fn unified_exec_network_denial_test( server: &wiremock::MockServer, -) -> Result<(TestCodex, SandboxPolicy)> { +) -> Result<(TestCodex, PermissionProfile)> { use codex_config::Constrained; use std::sync::Arc; use tempfile::TempDir; @@ -897,10 +901,7 @@ allow_local_binding = true /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ); - let sandbox_policy = permission_profile_for_config - .clone() - .to_legacy_sandbox_policy(home.path()) - .expect("workspace-write profile should project to legacy policy"); + let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) .with_cloud_requirements(managed_network_requirements_loader()) @@ -922,7 +923,7 @@ allow_local_binding = true "expected managed network proxy config to be present" ); - Ok((test, sandbox_policy)) + Ok((test, permission_profile)) } async fn mount_unified_exec_network_denial_responses( @@ -1040,7 +1041,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "stdin delta", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; let mut terminal_interaction = None; @@ -1160,7 +1161,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( submit_unified_exec_turn( &test, "delayed terminal interaction output", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1305,7 +1306,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { submit_unified_exec_turn( &test, "check poll event behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1395,7 +1396,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run metadata test", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run metadata test", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1494,7 +1495,7 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re submit_unified_exec_turn( &test, "run clamped max output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1577,7 +1578,7 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res submit_unified_exec_turn( &test, "run clamped write_stdin output test", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1642,7 +1643,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { submit_unified_exec_turn( &test, "check default pipe mode", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1709,7 +1710,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check tty enabled", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check tty enabled", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1776,7 +1777,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { submit_unified_exec_turn( &test, "watch early exit timing", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -1895,7 +1896,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { submit_unified_exec_turn( &test, "test write_stdin exit behavior", - SandboxPolicy::DangerFullAccess, + PermissionProfile::Disabled, ) .await?; @@ -2048,7 +2049,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() ]; mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "end on exit", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "end on exit", PermissionProfile::Disabled).await?; // We expect the ExecCommandEnd event to match the initial exec_command call_id. let end_event = wait_for_event_match(&test.codex, |msg| match msg { @@ -2114,6 +2115,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2123,11 +2127,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2209,6 +2213,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex .submit(Op::UserTurn { @@ -2218,11 +2225,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2314,7 +2321,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "run unified exec", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "run unified exec", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2432,12 +2439,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "exercise lag handling", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "exercise lag handling", PermissionProfile::Disabled).await?; // This is a worst case scenario for the truncate logic, and CI can spend a // while draining the lagged tail before the follow-up tool call completes. wait_for_event_with_timeout( @@ -2532,7 +2534,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn(&test, "check timeout", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "check timeout", PermissionProfile::Disabled).await?; loop { let event = test.codex.next_event().await.expect("event"); @@ -2608,12 +2610,7 @@ PY ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2683,6 +2680,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2692,12 +2692,12 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, // Important! - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2799,7 +2799,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let read_only_policy = SandboxPolicy::new_read_only_policy(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { environments: None, @@ -2808,11 +2810,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: read_only_policy, - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -2929,6 +2931,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let turn_cwd = cwd.path().to_path_buf(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex .submit(Op::UserTurn { @@ -2938,11 +2943,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), + cwd: turn_cwd, approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy, + permission_profile, model: session_model, effort: None, summary: None, @@ -3025,12 +3030,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { ]; let request_log = mount_sse_sequence(&server, responses).await; - submit_unified_exec_turn( - &test, - "summarize large output", - SandboxPolicy::DangerFullAccess, - ) - .await?; + submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -3148,7 +3148,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let response_mock = mount_sse_sequence(&server, vec![first_response, completion_response]).await; - submit_unified_exec_turn(&test, "fill session cache", SandboxPolicy::DangerFullAccess).await?; + submit_unified_exec_turn(&test, "fill session cache", PermissionProfile::Disabled).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) From e7bffc5a20e92cbc64d6c16a1b257d0b2e4cd5df Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 17 May 2026 09:05:44 -0700 Subject: [PATCH 044/114] [codex] Accept string input for Python turns (#23162) ## Summary - Allow thread.turn and turn.steer, including async variants, to accept RunInput so plain strings work alongside typed input objects. - Export RunInput and update the SDK artifact generator so regenerated turn methods keep the same signature and normalization. - Update docs, examples, notebook cells, and tests to use string shorthand for text-only turns while keeping typed inputs for multimodal input. ## Validation - uv run --extra dev ruff format . - uv run --extra dev ruff check --output-format=github . - python3 -m py_compile sdk/python/src/openai_codex/__init__.py sdk/python/src/openai_codex/api.py sdk/python/src/openai_codex/_inputs.py sdk/python/scripts/update_sdk_artifacts.py sdk/python/tests/test_public_api_signatures.py sdk/python/tests/test_app_server_streaming.py sdk/python/tests/test_app_server_turn_controls.py sdk/python/tests/test_real_app_server_integration.py - python3 -c "import json; json.load(open('sdk/python/notebooks/sdk_walkthrough.ipynb'))" - sdk/python/.venv/bin/python -c "import inspect, openai_codex; from openai_codex import Thread, AsyncThread, TurnHandle, AsyncTurnHandle, RunInput; funcs=[Thread.run, Thread.turn, AsyncThread.run, AsyncThread.turn, TurnHandle.steer, AsyncTurnHandle.steer]; assert all(inspect.signature(fn).parameters['input'].annotation == 'RunInput' for fn in funcs); assert RunInput is openai_codex.RunInput" --- sdk/python/README.md | 2 + sdk/python/docs/api-reference.md | 19 +++-- sdk/python/docs/getting-started.md | 1 + sdk/python/examples/02_turn_run/async.py | 4 +- sdk/python/examples/02_turn_run/sync.py | 4 +- .../examples/03_turn_stream_events/async.py | 4 +- .../examples/03_turn_stream_events/sync.py | 4 +- .../examples/05_existing_thread/async.py | 6 +- .../examples/05_existing_thread/sync.py | 6 +- .../06_thread_lifecycle_and_controls/async.py | 14 ++-- .../06_thread_lifecycle_and_controls/sync.py | 10 +-- sdk/python/examples/09_async_parity/sync.py | 4 +- .../10_error_handling_and_retry/async.py | 3 +- .../10_error_handling_and_retry/sync.py | 3 +- sdk/python/examples/11_cli_mini_app/async.py | 3 +- sdk/python/examples/11_cli_mini_app/sync.py | 3 +- .../12_turn_params_kitchen_sink/async.py | 3 +- .../12_turn_params_kitchen_sink/sync.py | 3 +- .../13_model_select_and_turn_params/async.py | 5 +- .../13_model_select_and_turn_params/sync.py | 5 +- sdk/python/examples/14_turn_controls/async.py | 10 +-- sdk/python/examples/14_turn_controls/sync.py | 12 +-- sdk/python/examples/README.md | 3 + sdk/python/notebooks/sdk_walkthrough.ipynb | 73 +++++++++++-------- sdk/python/scripts/update_sdk_artifacts.py | 8 +- sdk/python/src/openai_codex/__init__.py | 2 + sdk/python/src/openai_codex/api.py | 26 ++++--- sdk/python/tests/test_app_server_streaming.py | 22 ++++-- .../tests/test_app_server_turn_controls.py | 8 +- .../tests/test_public_api_signatures.py | 17 +++++ .../tests/test_real_app_server_integration.py | 18 ++--- 31 files changed, 171 insertions(+), 134 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 9107d15ba9..5b8e6b8178 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -102,6 +102,8 @@ target wheel. The SDK package version and runtime package version must match. - `Codex()` is eager and performs startup + `initialize` in the constructor. - Use context managers (`with Codex() as codex:`) to ensure shutdown. +- Plain strings are accepted anywhere a turn input is accepted; they are + shorthand for `TextInput(...)`. - Prefer `thread.run("...")` for the common case. Use `thread.turn(...)` when you need streaming, steering, or interrupt control. - For transient overload, use `retry_on_overload` from the package root. diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index 4bee871f8c..db78ff5f1d 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -3,7 +3,7 @@ Public surface of `openai_codex` for app-server v2. This SDK surface is experimental. Turn streams are routed by turn ID so one client can consume multiple active turns concurrently. -Thread and turn starts expose `approval_mode`. `ApprovalMode.auto_review` is the default; use `ApprovalMode.deny_all` to deny escalated permissions. +Thread starts default to `ApprovalMode.auto_review`; turn starts accept an optional `approval_mode` override. ## Package Entry @@ -23,6 +23,7 @@ from openai_codex import ( TurnResult, Input, InputItem, + RunInput, TextInput, ImageInput, LocalImageInput, @@ -147,16 +148,16 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` -- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` - `compact() -> ThreadCompactStartResponse` ### AsyncThread -- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` -- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` - `compact() -> Awaitable[ThreadCompactStartResponse]` @@ -185,7 +186,7 @@ Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, ### TurnHandle -- `steer(input: Input) -> TurnSteerResponse` +- `steer(input: str | Input) -> TurnSteerResponse` - `interrupt() -> TurnInterruptResponse` - `stream() -> Iterator[Notification]` - `run() -> TurnResult` @@ -197,7 +198,7 @@ Behavior notes: ### AsyncTurnHandle -- `steer(input: Input) -> Awaitable[TurnSteerResponse]` +- `steer(input: str | Input) -> Awaitable[TurnSteerResponse]` - `interrupt() -> Awaitable[TurnInterruptResponse]` - `stream() -> AsyncIterator[Notification]` - `run() -> Awaitable[TurnResult]` @@ -218,8 +219,12 @@ Behavior notes: InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput Input = list[InputItem] | InputItem +RunInput = Input | str ``` +Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: +`thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. + ## Public Types The SDK wrappers return and accept public app-server models wherever possible: diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 1df0026c68..b21db2a207 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -72,6 +72,7 @@ What happened: - `thread_start(...)` created a thread. - `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. +- plain strings are accepted anywhere a turn input is accepted; typed inputs are still available for multimodal and structured cases - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID diff --git a/sdk/python/examples/02_turn_run/async.py b/sdk/python/examples/02_turn_run/async.py index debfad8d6e..c4071cde47 100644 --- a/sdk/python/examples/02_turn_run/async.py +++ b/sdk/python/examples/02_turn_run/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Give 3 bullets about SIMD.")) + turn = await thread.turn("Give 3 bullets about SIMD.") result = await turn.run() print("thread_id:", thread.id) diff --git a/sdk/python/examples/02_turn_run/sync.py b/sdk/python/examples/02_turn_run/sync.py index c42bffa1f2..af22a220ba 100644 --- a/sdk/python/examples/02_turn_run/sync.py +++ b/sdk/python/examples/02_turn_run/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run() + result = thread.turn("Give 3 bullets about SIMD.").run() print("thread_id:", thread.id) print("turn_id:", result.id) diff --git a/sdk/python/examples/03_turn_stream_events/async.py b/sdk/python/examples/03_turn_stream_events/async.py index f0d3f1427d..e145b2222b 100644 --- a/sdk/python/examples/03_turn_stream_events/async.py +++ b/sdk/python/examples/03_turn_stream_events/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,7 +19,7 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - turn = await thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = await thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/03_turn_stream_events/sync.py b/sdk/python/examples/03_turn_stream_events/sync.py index 5a76bcb70f..f5a734dc38 100644 --- a/sdk/python/examples/03_turn_stream_events/sync.py +++ b/sdk/python/examples/03_turn_stream_events/sync.py @@ -9,11 +9,11 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Explain SIMD in 3 short bullets.")) + turn = thread.turn("Explain SIMD in 3 short bullets.") event_count = 0 saw_started = False diff --git a/sdk/python/examples/05_existing_thread/async.py b/sdk/python/examples/05_existing_thread/async.py index df3946ac87..e1f4db7105 100644 --- a/sdk/python/examples/05_existing_thread/async.py +++ b/sdk/python/examples/05_existing_thread/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -20,12 +20,12 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first_turn = await original.turn(TextInput("Tell me one fact about Saturn.")) + first_turn = await original.turn("Tell me one fact about Saturn.") _ = await first_turn.run() print("Created thread:", original.id) resumed = await codex.thread_resume(original.id) - second_turn = await resumed.turn(TextInput("Continue with one more fact.")) + second_turn = await resumed.turn("Continue with one more fact.") second = await second_turn.run() print(second.final_response) diff --git a/sdk/python/examples/05_existing_thread/sync.py b/sdk/python/examples/05_existing_thread/sync.py index a0f0d37e27..7110ebac7f 100644 --- a/sdk/python/examples/05_existing_thread/sync.py +++ b/sdk/python/examples/05_existing_thread/sync.py @@ -9,15 +9,15 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: # Create an initial thread and turn so we have a real thread to resume. original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = original.turn(TextInput("Tell me one fact about Saturn.")).run() + first = original.turn("Tell me one fact about Saturn.").run() print("Created thread:", original.id) # Resume the existing thread by ID. resumed = codex.thread_resume(original.id) - second = resumed.turn(TextInput("Continue with one more fact.")).run() + second = resumed.turn("Continue with one more fact.").run() print(second.final_response) diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py index 24afbde6c2..7a786a1d79 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/async.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - first = await ( - await thread.turn(TextInput("One sentence about structured planning.")) - ).run() - second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run() + first = await (await thread.turn("One sentence about structured planning.")).run() + second = await (await thread.turn("Now restate it for a junior engineer.")).run() reopened = await codex.thread_resume(thread.id) listing_active = await codex.thread_list(limit=20, archived=False) @@ -38,13 +36,11 @@ async def main() -> None: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = await ( - await resumed.turn(TextInput("Continue in one short sentence.")) - ).run() + resumed_result = await (await resumed.turn("Continue in one short sentence.")).run() forked = await codex.thread_fork(unarchived.id, model="gpt-5.4") forked_result = await ( - await forked.turn(TextInput("Take a different angle in one short sentence.")) + await forked.turn("Take a different angle in one short sentence.") ).run() compact_result = await unarchived.compact() diff --git a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py index 0958002dd9..744b56ebb7 100644 --- a/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py +++ b/sdk/python/examples/06_thread_lifecycle_and_controls/sync.py @@ -9,12 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - first = thread.turn(TextInput("One sentence about structured planning.")).run() - second = thread.turn(TextInput("Now restate it for a junior engineer.")).run() + first = thread.turn("One sentence about structured planning.").run() + second = thread.turn("Now restate it for a junior engineer.").run() reopened = codex.thread_resume(thread.id) listing_active = codex.thread_list(limit=20, archived=False) @@ -30,10 +30,10 @@ with Codex(config=runtime_config()) as codex: model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run() + resumed_result = resumed.turn("Continue in one short sentence.").run() forked = codex.thread_fork(unarchived.id, model="gpt-5.4") - forked_result = forked.turn(TextInput("Take a different angle in one short sentence.")).run() + forked_result = forked.turn("Take a different angle in one short sentence.").run() compact_result = unarchived.compact() diff --git a/sdk/python/examples/09_async_parity/sync.py b/sdk/python/examples/09_async_parity/sync.py index 4b5d567159..a7ac8abbd7 100644 --- a/sdk/python/examples/09_async_parity/sync.py +++ b/sdk/python/examples/09_async_parity/sync.py @@ -9,13 +9,13 @@ from _bootstrap import ensure_local_sdk_src, runtime_config, server_label ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: print("Server:", server_label(codex.metadata)) thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - turn = thread.turn(TextInput("Say hello in one sentence.")) + turn = thread.turn("Say hello in one sentence.") result = turn.run() print("Thread:", thread.id) diff --git a/sdk/python/examples/10_error_handling_and_retry/async.py b/sdk/python/examples/10_error_handling_and_retry/async.py index f1711bffc9..71f954544f 100644 --- a/sdk/python/examples/10_error_handling_and_retry/async.py +++ b/sdk/python/examples/10_error_handling_and_retry/async.py @@ -18,7 +18,6 @@ from openai_codex import ( AsyncCodex, JsonRpcError, ServerBusyError, - TextInput, is_retryable_error, ) from openai_codex.types import TurnStatus @@ -82,7 +81,7 @@ async def main() -> None: def _run_turn(thread, prompt: str): async def _inner(): - turn = await thread.turn(TextInput(prompt)) + turn = await thread.turn(prompt) return await turn.run() return _inner diff --git a/sdk/python/examples/10_error_handling_and_retry/sync.py b/sdk/python/examples/10_error_handling_and_retry/sync.py index c134063c80..9c0f9f30e5 100644 --- a/sdk/python/examples/10_error_handling_and_retry/sync.py +++ b/sdk/python/examples/10_error_handling_and_retry/sync.py @@ -13,7 +13,6 @@ from openai_codex import ( Codex, JsonRpcError, ServerBusyError, - TextInput, retry_on_overload, ) from openai_codex.types import TurnStatus @@ -23,7 +22,7 @@ with Codex(config=runtime_config()) as codex: try: result = retry_on_overload( - lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(), + lambda: thread.turn("Summarize retry best practices in 3 bullets.").run(), max_attempts=3, initial_delay_s=0.25, max_delay_s=2.0, diff --git a/sdk/python/examples/11_cli_mini_app/async.py b/sdk/python/examples/11_cli_mini_app/async.py index df9fc89b86..e2a695a924 100644 --- a/sdk/python/examples/11_cli_mini_app/async.py +++ b/sdk/python/examples/11_cli_mini_app/async.py @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -51,7 +50,7 @@ async def main() -> None: if user_input in {"/exit", "/quit"}: break - turn = await thread.turn(TextInput(user_input)) + turn = await thread.turn(user_input) usage = None status = None error = None diff --git a/sdk/python/examples/11_cli_mini_app/sync.py b/sdk/python/examples/11_cli_mini_app/sync.py index fd16f930dc..0bf906647c 100644 --- a/sdk/python/examples/11_cli_mini_app/sync.py +++ b/sdk/python/examples/11_cli_mini_app/sync.py @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( ThreadTokenUsageUpdatedNotification, @@ -46,7 +45,7 @@ with Codex(config=runtime_config()) as codex: if user_input in {"/exit", "/quit"}: break - turn = thread.turn(TextInput(user_input)) + turn = thread.turn(user_input) usage = None status = None error = None diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/async.py b/sdk/python/examples/12_turn_params_kitchen_sink/async.py index 258d674a88..f44ea229b0 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/async.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/async.py @@ -14,7 +14,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -49,7 +48,7 @@ async def main() -> None: ) turn = await thread.turn( - TextInput(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, diff --git a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py index feced36f20..160a9bf187 100644 --- a/sdk/python/examples/12_turn_params_kitchen_sink/sync.py +++ b/sdk/python/examples/12_turn_params_kitchen_sink/sync.py @@ -12,7 +12,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -43,7 +42,7 @@ with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) turn = thread.turn( - TextInput(PROMPT), + PROMPT, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, summary=SUMMARY, diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index 31a0c9c243..144169c46c 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -13,7 +13,6 @@ import asyncio from openai_codex import ( AsyncCodex, - TextInput, ) from openai_codex.types import ( Personality, @@ -91,7 +90,7 @@ async def main() -> None: ) first_turn = await thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ) @@ -101,7 +100,7 @@ async def main() -> None: print("items:", len(first.items)) second_turn = await thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index 9252f3913c..962665363f 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -11,7 +11,6 @@ ensure_local_sdk_src() from openai_codex import ( Codex, - TextInput, ) from openai_codex.types import ( Personality, @@ -88,7 +87,7 @@ with Codex(config=runtime_config()) as codex: ) first = thread.turn( - TextInput("Give one short sentence about reliable production releases."), + "Give one short sentence about reliable production releases.", model=selected_model.model, effort=selected_effort, ).run() @@ -97,7 +96,7 @@ with Codex(config=runtime_config()) as codex: print("items:", len(first.items)) second = thread.turn( - TextInput("Return JSON for a safe feature-flag rollout plan."), + "Return JSON for a safe feature-flag rollout plan.", cwd=str(Path.cwd()), effort=selected_effort, model=selected_model.model, diff --git a/sdk/python/examples/14_turn_controls/async.py b/sdk/python/examples/14_turn_controls/async.py index f044dadbbe..86e8def04f 100644 --- a/sdk/python/examples/14_turn_controls/async.py +++ b/sdk/python/examples/14_turn_controls/async.py @@ -11,7 +11,7 @@ ensure_local_sdk_src() import asyncio -from openai_codex import AsyncCodex, TextInput +from openai_codex import AsyncCodex async def main() -> None: @@ -19,10 +19,8 @@ async def main() -> None: thread = await codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"} ) - steer_turn = await thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = await thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = await steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -40,7 +38,7 @@ async def main() -> None: steer_preview = "".join(steer_deltas).strip() interrupt_turn = await thread.turn( - TextInput("Count from 1 to 200 with commas, then one summary sentence.") + "Count from 1 to 200 with commas, then one summary sentence." ) interrupt_result = await interrupt_turn.interrupt() diff --git a/sdk/python/examples/14_turn_controls/sync.py b/sdk/python/examples/14_turn_controls/sync.py index 6b6d046a43..c8f1a75e28 100644 --- a/sdk/python/examples/14_turn_controls/sync.py +++ b/sdk/python/examples/14_turn_controls/sync.py @@ -9,14 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config ensure_local_sdk_src() -from openai_codex import Codex, TextInput +from openai_codex import Codex with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) - steer_turn = thread.turn( - TextInput("Count from 1 to 40 with commas, then one summary sentence.") - ) - steer_result = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers.")) + steer_turn = thread.turn("Count from 1 to 40 with commas, then one summary sentence.") + steer_result = steer_turn.steer("Keep it brief and stop after 10 numbers.") steer_event_count = 0 steer_completed_status = None @@ -33,9 +31,7 @@ with Codex(config=runtime_config()) as codex: raise RuntimeError("stream ended without turn/completed") steer_preview = "".join(steer_deltas).strip() - interrupt_turn = thread.turn( - TextInput("Count from 1 to 200 with commas, then one summary sentence.") - ) + interrupt_turn = thread.turn("Count from 1 to 200 with commas, then one summary sentence.") interrupt_result = interrupt_turn.interrupt() interrupt_event_count = 0 diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index bef1a16c0b..eef3893721 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -8,6 +8,9 @@ Each example folder contains runnable versions: All examples intentionally use only public SDK exports from `openai_codex` and `openai_codex.types`. +Examples use plain strings for text-only turns and typed input objects for +multimodal or structured input lists. + ## Prerequisites - Python `>=3.10` diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1eb80b3a71..bd313a02b1 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -12,6 +12,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1b6614a5", "metadata": {}, "outputs": [], "source": [ @@ -97,6 +98,7 @@ { "cell_type": "code", "execution_count": null, + "id": "137a6d64", "metadata": {}, "outputs": [], "source": [ @@ -115,6 +117,7 @@ { "cell_type": "code", "execution_count": null, + "id": "5fae892d", "metadata": {}, "outputs": [], "source": [ @@ -122,46 +125,42 @@ "with Codex() as codex:\n", " # Open this URL and call `wait()` without canceling when completing login for real.\n", " login = codex.login_chatgpt()\n", - " canceled = login.cancel()\n", + " print('Please complete login at:', login.auth_url)\n", " completed = login.wait()\n", " account = codex.account()\n", "\n", " print('login.id:', login.login_id)\n", " print('login.auth_url:', login.auth_url)\n", - " print('login.cancel.status:', canceled.status)\n", " print('login.completed.success:', completed.success)\n", - " print('account.requires_openai_auth:', account.requires_openai_auth)\n" + " print('account:', account.email)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "ebdc04d9", "metadata": {}, "outputs": [], "source": [ "# Cell 3: simple sync conversation\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n", - " result = turn.run()\n", - " print('server:', server_label(codex.metadata))\n", - " print('status:', result.status)\n", - " print(result.final_response)\n", - " print('items:', len(result.items))\n" + " result = thread.run('Explain gradient descent in 3 bullets.')\n", + " print(result.final_response)\n" ] }, { "cell_type": "code", "execution_count": null, + "id": "bb4abb96", "metadata": {}, "outputs": [], "source": [ "# Cell 4: multi-turn continuity in same thread\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - "\n", - " first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n", - " second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n", + " first = thread.turn('Give a short summary of transformers.').run()\n", + " second = thread.turn('Now explain that to a high-school student.').run()\n", " print('first status:', first.status)\n", " print('second status:', second.status)\n", " print('second text:', second.final_response)\n" @@ -170,14 +169,15 @@ { "cell_type": "code", "execution_count": null, + "id": "8b0c80fd", "metadata": {}, "outputs": [], "source": [ "# Cell 5: full thread lifecycle and branching (sync)\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = thread.turn(TextInput('One sentence about structured planning.')).run()\n", - " second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n", + " first = thread.turn('One sentence about structured planning.').run()\n", + " second = thread.turn('Now restate it for a junior engineer.').run()\n", "\n", " reopened = codex.thread_resume(thread.id)\n", " listing_active = codex.thread_list(limit=20, archived=False)\n", @@ -193,10 +193,10 @@ " model='gpt-5.4',\n", " config={'model_reasoning_effort': 'high'},\n", " )\n", - " resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n", + " resumed_result = resumed.turn('Continue in one short sentence.').run()\n", "\n", " forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n", + " forked_result = forked.turn('Take a different angle in one short sentence.').run()\n", "\n", " compact_result = unarchived.compact()\n", "\n", @@ -214,6 +214,7 @@ { "cell_type": "code", "execution_count": null, + "id": "310db8c0", "metadata": {}, "outputs": [], "source": [ @@ -242,7 +243,7 @@ "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " turn = thread.turn(\n", - " TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n", + " 'Propose a safe production feature-flag rollout. Return JSON matching the schema.',\n", " cwd=str(Path.cwd()),\n", " effort=ReasoningEffort.medium,\n", " model='gpt-5.4',\n", @@ -259,6 +260,7 @@ { "cell_type": "code", "execution_count": null, + "id": "7a33c97d", "metadata": {}, "outputs": [], "source": [ @@ -321,7 +323,7 @@ " thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n", "\n", " first = thread.turn(\n", - " TextInput('Give one short sentence about reliable production releases.'),\n", + " 'Give one short sentence about reliable production releases.',\n", " model=selected_model.model,\n", " effort=selected_effort,\n", " ).run()\n", @@ -329,7 +331,7 @@ " print('items:', len(first.items))\n", "\n", " second = thread.turn(\n", - " TextInput('Return JSON for a safe feature-flag rollout plan.'),\n", + " 'Return JSON for a safe feature-flag rollout plan.',\n", " cwd=str(Path.cwd()),\n", " effort=selected_effort,\n", " model=selected_model.model,\n", @@ -345,6 +347,7 @@ { "cell_type": "code", "execution_count": null, + "id": "e9aef26a", "metadata": {}, "outputs": [], "source": [ @@ -364,6 +367,7 @@ { "cell_type": "code", "execution_count": null, + "id": "a0cecc6c", "metadata": {}, "outputs": [], "source": [ @@ -382,6 +386,7 @@ { "cell_type": "code", "execution_count": null, + "id": "91afa2b8", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +395,7 @@ " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", "\n", " result = retry_on_overload(\n", - " lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n", + " lambda: thread.turn('List 5 failure modes in distributed systems.').run(),\n", " max_attempts=3,\n", " initial_delay_s=0.25,\n", " max_delay_s=2.0,\n", @@ -402,6 +407,7 @@ { "cell_type": "code", "execution_count": null, + "id": "103be934", "metadata": {}, "outputs": [], "source": [ @@ -412,8 +418,8 @@ "async def async_lifecycle_demo():\n", " async with AsyncCodex() as codex:\n", " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n", - " second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n", + " first = await (await thread.turn('One sentence about structured planning.')).run()\n", + " second = await (await thread.turn('Now restate it for a junior engineer.')).run()\n", "\n", " reopened = await codex.thread_resume(thread.id)\n", " listing_active = await codex.thread_list(limit=20, archived=False)\n", @@ -429,10 +435,10 @@ " model='gpt-5.4',\n", " config={'model_reasoning_effort': 'high'},\n", " )\n", - " resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n", + " resumed_result = await (await resumed.turn('Continue in one short sentence.')).run()\n", "\n", " forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n", - " forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n", + " forked_result = await (await forked.turn('Take a different angle in one short sentence.')).run()\n", "\n", " compact_result = await unarchived.compact()\n", "\n", @@ -453,6 +459,7 @@ { "cell_type": "code", "execution_count": null, + "id": "365aa10c", "metadata": {}, "outputs": [], "source": [ @@ -463,9 +470,9 @@ "async def async_stream_demo():\n", " async with AsyncCodex() as codex:\n", " thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", - " steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n", + " steer_turn = await thread.turn('Count from 1 to 40 with commas, then one summary sentence.')\n", "\n", - " steer_result = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n", + " steer_result = await steer_turn.steer('Keep it brief and stop after 10 numbers.')\n", "\n", " steer_event_count = 0\n", " steer_completed_status = None\n", @@ -482,7 +489,7 @@ " raise RuntimeError('stream ended without turn/completed')\n", " steer_preview = ''.join(steer_deltas).strip()\n", "\n", - " interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n", + " interrupt_turn = await thread.turn('Count from 1 to 200 with commas, then one summary sentence.')\n", " interrupt_result = await interrupt_turn.interrupt()\n", "\n", " interrupt_event_count = 0\n", @@ -516,13 +523,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.10+" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" } }, "nbformat": 4, diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a40a4aa9c3..5cad14cf5e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1040,12 +1040,12 @@ def _render_thread_block( lines = [ " def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> TurnHandle:", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", @@ -1065,13 +1065,13 @@ def _render_async_thread_block( lines = [ " async def turn(", " self,", - " input: Input,", + " input: RunInput,", " *,", *_approval_mode_override_signature_lines(), *_kw_signature_lines(turn_fields), " ) -> AsyncTurnHandle:", " await self._codex._ensure_initialized()", - " wire_input = _to_wire_input(input)", + " wire_input = _to_wire_input(_normalize_run_input(input))", _approval_mode_assignment_line("_approval_mode_override_settings"), " params = TurnStartParams(", " thread_id=self.id,", diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index b3db7e3175..3bc018a769 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -14,6 +14,7 @@ from .api import ( InputItem, LocalImageInput, MentionInput, + RunInput, SkillInput, TextInput, Thread, @@ -54,6 +55,7 @@ __all__ = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index 347f95eeae..f14a311789 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -12,7 +12,7 @@ from ._approval_mode import ( from ._initialize_metadata import validate_initialize_metadata from ._inputs import ( ImageInput as ImageInput, - Input, + Input as Input, InputItem as InputItem, LocalImageInput as LocalImageInput, MentionInput as MentionInput, @@ -534,7 +534,7 @@ class Thread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -554,7 +554,7 @@ class Thread: # BEGIN GENERATED: Thread.flat_methods def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -566,7 +566,7 @@ class Thread: service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnHandle: - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -617,7 +617,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> TurnResult: turn = await self.turn( - _normalize_run_input(input), + input, approval_mode=approval_mode, cwd=cwd, effort=effort, @@ -637,7 +637,7 @@ class AsyncThread: # BEGIN GENERATED: AsyncThread.flat_methods async def turn( self, - input: Input, + input: RunInput, *, approval_mode: ApprovalMode | None = None, cwd: str | None = None, @@ -650,7 +650,7 @@ class AsyncThread: summary: ReasoningSummary | None = None, ) -> AsyncTurnHandle: await self._codex._ensure_initialized() - wire_input = _to_wire_input(input) + wire_input = _to_wire_input(_normalize_run_input(input)) approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) params = TurnStartParams( thread_id=self.id, @@ -694,8 +694,12 @@ class TurnHandle: thread_id: str id: str - def steer(self, input: Input) -> TurnSteerResponse: - return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input)) + def steer(self, input: RunInput) -> TurnSteerResponse: + return self._client.turn_steer( + self.thread_id, + self.id, + _to_wire_input(_normalize_run_input(input)), + ) def interrupt(self) -> TurnInterruptResponse: return self._client.turn_interrupt(self.thread_id, self.id) @@ -730,12 +734,12 @@ class AsyncTurnHandle: thread_id: str id: str - async def steer(self, input: Input) -> TurnSteerResponse: + async def steer(self, input: RunInput) -> TurnSteerResponse: await self._codex._ensure_initialized() return await self._codex._client.turn_steer( self.thread_id, self.id, - _to_wire_input(input), + _to_wire_input(_normalize_run_input(input)), ) async def interrupt(self) -> TurnInterruptResponse: diff --git a/sdk/python/tests/test_app_server_streaming.py b/sdk/python/tests/test_app_server_streaming.py index 02d089f2f1..3b79d9def3 100644 --- a/sdk/python/tests/test_app_server_streaming.py +++ b/sdk/python/tests/test_app_server_streaming.py @@ -11,7 +11,7 @@ from app_server_helpers import ( streaming_response, ) -from openai_codex import AsyncCodex, Codex, TextInput +from openai_codex import AsyncCodex, Codex from openai_codex.generated.v2_all import ( AgentMessageDeltaNotification, TurnCompletedNotification, @@ -26,8 +26,9 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - stream = thread.turn(TextInput("stream please")).stream() + stream = thread.turn("stream please").stream() events = list(stream) + request = harness.responses.single_request() assert { "deltas": [ @@ -36,6 +37,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -44,6 +46,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["he", "llo"], "agent_messages": ["hello"], + "request_user_texts": ["stream please"], "completed_statuses": [TurnStatus.completed], } @@ -55,7 +58,7 @@ def test_turn_run_returns_completed_turn(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("complete this turn")) + turn = thread.turn("complete this turn") completed = turn.run() assert { @@ -83,8 +86,9 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: thread = await codex.thread_start() - turn = await thread.turn(TextInput("async stream please")) + turn = await thread.turn("async stream please") events = [event async for event in turn.stream()] + request = harness.responses.single_request() assert { "deltas": [ @@ -93,6 +97,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: if isinstance(event.payload, AgentMessageDeltaNotification) ], "agent_messages": agent_message_texts(events), + "request_user_texts": request.message_input_texts("user")[-1:], "completed_statuses": [ event.payload.turn.status for event in events @@ -101,6 +106,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None: } == { "deltas": ["as", "ync"], "agent_messages": ["async"], + "request_user_texts": ["async stream please"], "completed_statuses": [TurnStatus.completed], } @@ -178,8 +184,8 @@ def test_interleaved_sync_turn_streams_route_by_turn_id(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: first_thread = codex.thread_start() second_thread = codex.thread_start() - first_turn = first_thread.turn(TextInput("first")) - second_turn = second_thread.turn(TextInput("second")) + first_turn = first_thread.turn("first") + second_turn = second_thread.turn("second") first_stream = first_turn.stream() second_stream = second_turn.stream() @@ -231,8 +237,8 @@ def test_interleaved_async_turn_streams_route_by_turn_id(tmp_path) -> None: async with AsyncCodex(config=harness.app_server_config()) as codex: first_thread = await codex.thread_start() second_thread = await codex.thread_start() - first_turn = await first_thread.turn(TextInput("async first")) - second_turn = await second_thread.turn(TextInput("async second")) + first_turn = await first_thread.turn("async first") + second_turn = await second_thread.turn("async second") first_stream = first_turn.stream() second_stream = second_turn.stream() diff --git a/sdk/python/tests/test_app_server_turn_controls.py b/sdk/python/tests/test_app_server_turn_controls.py index af02f6b74f..dd994b88f9 100644 --- a/sdk/python/tests/test_app_server_turn_controls.py +++ b/sdk/python/tests/test_app_server_turn_controls.py @@ -3,7 +3,7 @@ from __future__ import annotations from app_server_harness import AppServerHarness from app_server_helpers import agent_message_texts, streaming_response -from openai_codex import Codex, TextInput +from openai_codex import Codex from openai_codex.generated.v2_all import TurnStatus @@ -21,9 +21,9 @@ def test_turn_steer_adds_follow_up_input(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - turn = thread.turn(TextInput("Start a steerable turn.")) + turn = thread.turn("Start a steerable turn.") harness.responses.wait_for_requests(1) - steer = turn.steer(TextInput("Use this steering input.")) + steer = turn.steer("Use this steering input.") events = list(turn.stream()) requests = harness.responses.wait_for_requests(2) @@ -61,7 +61,7 @@ def test_turn_interrupt_stops_active_turn_and_follow_up_runs(tmp_path) -> None: with Codex(config=harness.app_server_config()) as codex: thread = codex.thread_start() - interrupted_turn = thread.turn(TextInput("Start a long turn.")) + interrupted_turn = thread.turn("Start a long turn.") harness.responses.wait_for_requests(1) interrupt_response = interrupted_turn.interrupt() completed = interrupted_turn.run() diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index c7a00d3897..9a1212dfdf 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -40,6 +40,7 @@ EXPECTED_ROOT_EXPORTS = [ "TurnResult", "Input", "InputItem", + "RunInput", "TextInput", "ImageInput", "LocalImageInput", @@ -166,6 +167,22 @@ def test_turn_run_methods_return_turn_result() -> None: ) +def test_turn_input_methods_accept_string_shortcut() -> None: + """Every public turn-input method should accept strings and typed inputs.""" + funcs = [ + Thread.run, + Thread.turn, + AsyncThread.run, + AsyncThread.turn, + TurnHandle.steer, + AsyncTurnHandle.steer, + ] + + assert {fn: inspect.signature(fn).parameters["input"].annotation for fn in funcs} == ( + dict.fromkeys(funcs, "RunInput") + ) + + def test_root_exports_approval_mode() -> None: """The root package should expose the high-level approval mode enum.""" assert [(mode.name, mode.value) for mode in ApprovalMode] == [ diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index 08f07c986c..5c4ec83fd4 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -234,14 +234,14 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = thread.turn(TextInput("hello")).run() + result = thread.turn("hello").run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -331,7 +331,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( """ import asyncio import json - from openai_codex import AsyncCodex, TextInput + from openai_codex import AsyncCodex async def main(): async with AsyncCodex() as codex: @@ -339,7 +339,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - result = await (await thread.turn(TextInput("say ok"))).run() + result = await (await thread.turn("say ok")).run() print(json.dumps({ "thread_id": thread.id, "turn_id": result.id, @@ -458,14 +458,14 @@ def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) -> textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Reply with one short sentence.")) + turn = thread.turn("Reply with one short sentence.") saw_delta = False saw_completed = False for event in turn.stream(): @@ -491,16 +491,16 @@ def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None: textwrap.dedent( """ import json - from openai_codex import Codex, TextInput + from openai_codex import Codex with Codex() as codex: thread = codex.thread_start( model="gpt-5.4", config={"model_reasoning_effort": "high"}, ) - turn = thread.turn(TextInput("Count from 1 to 200 with commas.")) + turn = thread.turn("Count from 1 to 200 with commas.") turn.interrupt() - follow_up = thread.turn(TextInput("Say 'ok' only.")).run() + follow_up = thread.turn("Say 'ok' only.").run() print(json.dumps({"status": follow_up.status.value})) """ ), From 64ead6a83a6ed348229bc98a9b5d8b0c550d8305 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Sun, 17 May 2026 20:07:32 -0700 Subject: [PATCH 045/114] Add exec-server websocket keepalive (#23226) ## Summary - send periodic websocket Ping frames from outbound exec-server websocket clients - cover direct exec-server websocket clients plus rendezvous harness/executor websocket connections - keep inbound axum-accepted exec-server websocket connections passive - add focused keepalive coverage for direct and relay websocket paths ## Validation - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-unit-tests --test_filter='websocket_connection_sends_keepalive_ping|harness_connection_sends_keepalive_ping|multiplexed_executor_sends_keepalive_ping' - /Users/starr/code/openai/project/dotslash-gen/bin/bazel test //codex-rs/exec-server:exec-server-relay-test --test_filter=multiplexed_remote_executor_routes_independent_virtual_streams --- codex-rs/exec-server/src/connection.rs | 191 ++++++++++++++++++++++--- codex-rs/exec-server/src/relay.rs | 159 +++++++++++++++++--- 2 files changed, 304 insertions(+), 46 deletions(-) diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index d5ff1f966f..cf504bbea7 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -30,6 +30,10 @@ use tokio::io::BufWriter; pub(crate) const CHANNEL_CAPACITY: usize = 128; const STDIO_TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(2); +#[cfg(test)] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_millis(25); +#[cfg(not(test))] +pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); #[derive(Debug)] pub(crate) enum JsonRpcConnectionEvent { @@ -320,18 +324,32 @@ impl JsonRpcConnection { S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ) } pub(crate) fn from_axum_websocket(stream: AxumWebSocket, connection_label: String) -> Self { let (websocket_writer, websocket_reader) = stream.split(); - Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label) + Self::from_websocket_parts( + websocket_writer, + websocket_reader, + connection_label, + // Axum only wraps inbound exec-server websocket accepts. Outbound websocket clients + // own keepalive pings so one side does not accidentally create redundant traffic. + /*keepalive_interval*/ + None, + ) } fn from_websocket_parts( mut websocket_writer: W, mut websocket_reader: R, connection_label: String, + keepalive_interval: Option, ) -> Self where W: Sink + Unpin + Send + 'static, @@ -404,30 +422,54 @@ impl JsonRpcConnection { }); let writer_task = tokio::spawn(async move { - while let Some(message) = outgoing_rx.recv().await { - match serialize_jsonrpc_message(&message) { - Ok(encoded) => { - if let Err(err) = websocket_writer.send(M::from_text(encoded)).await { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to write websocket JSON-RPC message to {connection_label}: {err}" - )), + if let Some(keepalive_interval) = keepalive_interval { + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + keepalive_interval, + keepalive_interval, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, ) - .await; - break; + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; + break; + } + } + _ = keepalive.tick() => { + if let Err(err) = websocket_writer.send(M::ping()).await { + send_disconnected( + &incoming_tx, + &disconnected_tx, + Some(format!( + "failed to write websocket ping to {connection_label}: {err}" + )), + ) + .await; + break; + } } } - Err(err) => { - send_disconnected( - &incoming_tx, - &disconnected_tx, - Some(format!( - "failed to serialize JSON-RPC message for {connection_label}: {err}" - )), - ) - .await; + } + } else { + while let Some(message) = outgoing_rx.recv().await { + if let Err(reason) = send_websocket_jsonrpc_message( + &mut websocket_writer, + &connection_label, + &message, + ) + .await + { + send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await; break; } } @@ -458,6 +500,7 @@ enum JsonRpcWebSocketFrame { trait JsonRpcWebSocketMessage: Send + 'static { fn parse_jsonrpc_frame(self) -> Result; fn from_text(text: String) -> Self; + fn ping() -> Self; } impl JsonRpcWebSocketMessage for Message { @@ -479,6 +522,10 @@ impl JsonRpcWebSocketMessage for Message { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } impl JsonRpcWebSocketMessage for AxumWebSocketMessage { @@ -500,6 +547,10 @@ impl JsonRpcWebSocketMessage for AxumWebSocketMessage { fn from_text(text: String) -> Self { Self::Text(text.into()) } + + fn ping() -> Self { + Self::Ping(Vec::new().into()) + } } async fn send_disconnected( @@ -538,6 +589,100 @@ where writer.flush().await } +async fn send_websocket_jsonrpc_message( + websocket_writer: &mut W, + connection_label: &str, + message: &JSONRPCMessage, +) -> Result<(), String> +where + W: Sink + Unpin, + M: JsonRpcWebSocketMessage, + E: std::fmt::Display, +{ + match serialize_jsonrpc_message(message) { + Ok(encoded) => websocket_writer + .send(M::from_text(encoded)) + .await + .map_err(|err| { + format!("failed to write websocket JSON-RPC message to {connection_label}: {err}") + }), + Err(err) => Err(format!( + "failed to serialize JSON-RPC message for {connection_label}: {err}" + )), + } +} + fn serialize_jsonrpc_message(message: &JSONRPCMessage) -> Result { serde_json::to_string(message) } + +#[cfg(test)] +mod tests { + use std::pin::Pin; + + use futures::channel::mpsc as futures_mpsc; + use futures::stream; + use futures::task::Context; + use futures::task::Poll; + use tokio::time::timeout; + + use super::*; + + struct TestWebSocketSink { + message_tx: futures_mpsc::UnboundedSender, + } + + impl Sink for TestWebSocketSink { + type Error = std::convert::Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.get_mut() + .message_tx + .unbounded_send(item) + .expect("test websocket receiver should stay open"); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn websocket_connection_sends_keepalive_ping() { + let (message_tx, mut message_rx) = futures_mpsc::unbounded::(); + let websocket_writer = TestWebSocketSink { message_tx }; + let websocket_reader = stream::pending::>(); + let connection = JsonRpcConnection::from_websocket_parts( + websocket_writer, + websocket_reader, + "test".into(), + Some(WEBSOCKET_KEEPALIVE_INTERVAL), + ); + + let message = timeout(Duration::from_secs(1), message_rx.next()) + .await + .expect("keepalive ping should arrive before timeout") + .expect("keepalive ping should be sent"); + assert!(matches!(message, Message::Ping(_))); + + drop(connection); + } +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index bce787cfc2..7470a6290c 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -19,6 +19,7 @@ use crate::connection::CHANNEL_CAPACITY; use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL; use crate::relay_proto::RelayData; use crate::relay_proto::RelayMessageFrame; use crate::relay_proto::RelayResume; @@ -262,24 +263,42 @@ where return; } + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut next_seq = 0u32; - while let Some(message) = outgoing_rx.recv().await { - let payload = match jsonrpc_payload(&message) { - Ok(payload) => payload, - Err(err) => { - warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); - break; + loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + let payload = match jsonrpc_payload(&message) { + Ok(payload) => payload, + Err(err) => { + warn!("failed to serialize JSON-RPC payload for relay transport: {err}"); + break; + } + }; + let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); + next_seq = next_seq.wrapping_add(1); + if websocket_writer + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + let _ = disconnected_tx.send(true); + break; + } } - }; - let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload); - next_seq = next_seq.wrapping_add(1); - if websocket_writer - .send(Message::Binary(encode_relay_message_frame(&frame).into())) - .await - .is_err() - { - let _ = disconnected_tx.send(true); - break; } } }); @@ -303,13 +322,30 @@ pub(crate) async fn run_multiplexed_executor( let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); let writer_task = tokio::spawn(async move { - while let Some(encoded) = physical_outgoing_rx.recv().await { - if websocket_writer - .send(Message::Binary(encoded.into())) - .await - .is_err() - { - break; + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe_encoded = physical_outgoing_rx.recv() => { + let Some(encoded) = maybe_encoded else { + break; + }; + if websocket_writer + .send(Message::Binary(encoded.into())) + .await + .is_err() + { + break; + } + } + _ = keepalive.tick() => { + if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + } } } }); @@ -453,3 +489,80 @@ fn spawn_virtual_stream( disconnected_tx, } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::net::TcpListener; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message; + + use super::*; + + fn test_runtime_paths() -> anyhow::Result { + crate::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + ) + .map_err(anyhow::Error::from) + } + + #[tokio::test] + async fn multiplexed_executor_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let executor_task = tokio::spawn(run_multiplexed_executor( + client_websocket, + ConnectionProcessor::new(test_runtime_paths()?), + )); + + read_keepalive_ping(&mut server_websocket).await?; + + executor_task.abort(); + let _ = executor_task.await; + Ok(()) + } + + #[tokio::test] + async fn harness_connection_sends_keepalive_ping() -> anyhow::Result<()> { + let (client_websocket, mut server_websocket) = websocket_pair().await?; + let connection = harness_connection_from_websocket(client_websocket, "test".to_string()); + + read_keepalive_ping(&mut server_websocket).await?; + + drop(connection); + Ok(()) + } + + async fn websocket_pair() -> anyhow::Result<( + WebSocketStream>, + WebSocketStream, + )> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + accept_async(stream).await.map_err(anyhow::Error::from) + }); + let (client_websocket, _) = connect_async(websocket_url).await?; + let server_websocket = server_task.await??; + Ok((client_websocket, server_websocket)) + } + + async fn read_keepalive_ping( + websocket: &mut WebSocketStream, + ) -> anyhow::Result<()> { + loop { + let Some(message) = timeout(Duration::from_secs(1), websocket.next()).await? else { + anyhow::bail!("websocket closed before keepalive ping"); + }; + match message? { + Message::Ping(_) => return Ok(()), + Message::Binary(_) | Message::Text(_) | Message::Pong(_) | Message::Frame(_) => {} + Message::Close(_) => anyhow::bail!("websocket closed before keepalive ping"), + } + } + } +} From 22dd9ad3929253ed24d7ee4f10f238e95ab25f37 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 09:59:34 +0200 Subject: [PATCH 046/114] Densify and version memory summaries (#23148) ## Why `memory_summary.md` is injected into every session, so its value depends on staying compact, navigational, and easy to regenerate when the expected shape changes. The previous consolidation prompt encouraged a broad actionable inventory and allowed older summary structures to be patched in place, which makes it easier for stale or overly verbose summaries to keep accumulating. This change makes the summary format explicitly versioned and biases Phase 2 memory consolidation toward denser prompt-loaded context. ## What changed - Require `memory_summary.md` to begin with an exact `v1` header. - Teach consolidation to regenerate `memory_summary.md` from scratch when the header is missing or incompatible, while still allowing incremental updates to `MEMORY.md`. - Tighten the `memory_summary.md` instructions so it acts as a compact routing/index layer instead of a second handbook. - Lower `MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT` from `5_000` to `2_500` so the runtime prompt budget matches the denser summary target. ## Verification Not run; this is a prompt/template update plus a prompt budget constant change. --- codex-rs/memories/read/src/lib.rs | 2 +- .../write/templates/memories/consolidation.md | 96 +++++++++++++------ 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/codex-rs/memories/read/src/lib.rs b/codex-rs/memories/read/src/lib.rs index 2f7b7645e4..ac6aa4d7f1 100644 --- a/codex-rs/memories/read/src/lib.rs +++ b/codex-rs/memories/read/src/lib.rs @@ -13,7 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use prompts::build_memory_tool_developer_instructions; -const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 5_000; +const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500; pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf { codex_home.join("memories") diff --git a/codex-rs/memories/write/templates/memories/consolidation.md b/codex-rs/memories/write/templates/memories/consolidation.md index cc586bd4c1..0eef7580b5 100644 --- a/codex-rs/memories/write/templates/memories/consolidation.md +++ b/codex-rs/memories/write/templates/memories/consolidation.md @@ -20,8 +20,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE Folder structure (under {{ memory_root }}/): - memory_summary.md - - Always loaded into the system prompt. Must remain informative and highly navigational, - but still discriminative enough to guide retrieval. + - Always loaded into the system prompt. First line must be exactly `v1`. + Must stay dense, highly navigational, and discriminative enough to guide retrieval. - MEMORY.md - Handbook entries. Used to grep for keywords; aggregated insights from rollouts; pointers to rollout summaries if certain past rollouts are very relevant. @@ -133,7 +133,8 @@ Under `{{ memory_root }}/`: - merged memories; produce a lightly clustered version if applicable - `rollout_summaries/*.md` - `memory_summary.md` - - read the existing summary so updates stay consistent + - read the existing summary so updates stay consistent only if its first line is exactly `v1`; + otherwise treat the summary as schema-incompatible and regenerate the whole file from scratch - `skills/*` - read existing skills so updates are incremental and non-duplicative {{ memory_extensions_primary_inputs }} @@ -143,6 +144,8 @@ Mode selection: and `skills/`). - INCREMENTAL UPDATE: existing artifacts already exist and `raw_memories.md` mostly contains new additions. +- Summary schema reset: if `memory_summary.md` is missing, empty, or does not start with exactly + `v1`, regenerate only `memory_summary.md` from scratch after `MEMORY.md` is current. Memory workspace diff: @@ -183,6 +186,8 @@ Rules: - If there is no meaningful signal to add beyond what already exists, keep outputs minimal. - You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date. +- `memory_summary.md` must start with the exact line `v1`; if it does not, rewrite the entire + file rather than patching the previous summary in place. - Follow the format and schema of the artifacts below. - Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the signal determine the granularity and depth. @@ -441,14 +446,40 @@ task group/task` from the full raw inventory so you can have a better overview. - Prefer placing reusable user preferences in `## User preferences` and the rest of the durable know-how in `## Reusable knowledge` and `## Failures and how to do differently`. - Use `memory_summary.md` as the cross-task summary layer, not the place for project-specific - runbooks. It should stay compact in narrative/profile sections, but its `## User preferences` - section is the main actionable payload and may be much longer when that helps future agents - avoid repeated user steering. + runbooks. Its `## User preferences` section is the main actionable payload, but it should + still stay compact, deduplicated, and limited to preferences likely to change future behavior. ============================================================ 2) `memory_summary.md` FORMAT (STRICT) ============================================================ +File header: + +The file must begin exactly: + +```md +v1 + +## User Profile +``` + +- The first line must be exactly `v1` with no leading/trailing whitespace and no frontmatter + before it. +- If the existing `memory_summary.md` first line is not exactly `v1`, discard the old summary + structure and regenerate the entire file from the finalized `MEMORY.md`, skills, and current + rollout evidence. + +Density objective (strict): + +- `memory_summary.md` is prompt-loaded context, so optimize for high signal per token. +- Keep only high-level, cross-task signal and brief routing summaries. Put details, provenance, + runbooks, and task-local nuance in `MEMORY.md`, skills, or rollout summaries. +- Deduplicate aggressively. If two bullets would cause the same future behavior or route to the + same `MEMORY.md` area, merge them or keep the sharper one. +- Prefer short, concrete bullets over narrative explanation. Delete low-signal caveats, + examples, and historical detail unless they change future agent behavior. +- Give directly links to important information to maximize the retrieval efficiency. + Format: ## User Profile @@ -474,15 +505,15 @@ For example, include (when known): You may end with short fun facts if they are real and useful, but keep the main profile concrete and grounded. Do not let the optional fun-facts tail make the rest of the section more stylized or abstract. -This entire section is free-form, <= 500 words. +This entire section is free-form, <= 350 words. ## User preferences Include a dedicated bullet list of actionable user preferences that are likely to matter again, not just inside one task group. This section should be more concrete and easier to apply than `## User Profile`. Prefer preferences that repeatedly save user keystrokes or avoid predictable interruption. -This section may be long. Do not compress it to just a few umbrella bullets when `MEMORY.md` -contains many distinct actionable preferences. +Keep it dense and non-duplicative. Include only stable or high-leverage preferences that would +change future agent behavior across recurring workflows. Treat this as the main actionable payload of `memory_summary.md`. For example, include (when known): @@ -500,16 +531,12 @@ Rules: - Keep each bullet actionable and future-facing. - Default to lifting or lightly adapting strong bullets from `MEMORY.md` `## User preferences` rather than rewriting them into smoother higher-level summaries. -- Preserve more of the user's original point than a terse summary would. Prefer evidence-aware - bullets that still keep some original wording over abstract umbrella summaries. +- Preserve the user's original point when it is compact and behavior-changing; otherwise compress + to the shortest faithful wording. - When a short quoted or near-verbatim phrase makes the preference easier to recognize or grep for later, keep that phrase in the bullet instead of replacing it with an abstraction. -- Do not over-merge adjacent preferences. If several distinct preferences would change different - future defaults, keep them as separate bullets. -- Prefer many narrow actionable bullets over a few broad umbrella bullets. -- Prefer a broad actionable inventory over a short highly deduped list. -- Do not treat 5-10 bullets as an implicit target; long-lived memory sets may justify a much - longer list. +- Merge adjacent preferences unless they would change different future defaults. +- Prefer a compact set of sharp bullets over a broad inventory. - Do not require a preference to be broad across task families. If it is likely to matter again in a recurring workflow, it belongs here. - When deciding whether to include a preference, ask whether omitting it would make the next @@ -542,16 +569,20 @@ For example, include (when known): This is a compact index to help future agents quickly find details in `MEMORY.md`, `skills/`, and `rollout_summaries/`. -Treat it as a routing/index layer, not a mini-handbook: +Treat it as a dense routing/index layer, not a mini-handbook: - tell future agents what to search first, - preserve enough specificity to route into the right `MEMORY.md` block quickly. +- keep topic descriptions brief; delete stale, duplicated, or low-signal topics even if they + existed in the previous summary. Topic selection and quality rules: - Organize the index first by cwd / project scope, then by topic. - Split the index into a recent high-utility window and older topics. - Do not target a fixed topic count. Include informative topics and omit low-signal noise. +- Keep the index current. Feel free to restructure, rename, merge, or delete topics when the + current `MEMORY.md` organization or evidence has changed. - Prefer grouping by task family / workflow intent, not by incidental tool overlap alone. - Order topics by utility, using `updated_at` recency as a strong default proxy unless there is strong contrary evidence. @@ -588,7 +619,7 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): - Within each scope, order day subsections by recency. - If a scope has only one meaningful recent day, include only that day for that scope. - For each recent-day subsection inside a scope, prioritize informative, likely-to-recur topics and make - those entries richer (better keywords, clearer descriptions, and useful recent learnings); + those entries denser (better keywords, brief descriptions, and useful recent learnings); do not spend much space on trivial tasks touched that day. - Preserve routing coverage for `MEMORY.md` in the overall index. If a scope/day includes less useful topics, include shorter/compact entries for routing rather than dropping them. @@ -596,16 +627,16 @@ Recent Active Memory Window behavior (scope-first, then day-ordered): appears; do not duplicate it under multiple day sections. - If a topic spans multiple scopes and retrieval would differ by scope, split it. Otherwise, place it under the dominant scope and mention the secondary scope in the description. -- Recent-day entries should be richer than older-topic entries: stronger keywords, clearer - descriptions, and concise recent learnings/change notes. +- Recent-day entries should be more informative than older-topic entries through stronger + keywords and concise recent learnings/change notes, not longer prose. - Group similar tasks/topics together when it improves routing clarity. - Do not over cluster topics together, especially when they contain distinct task intents. Recent-topic format: - : , , , ... - - desc: - - learnings: + - desc: + - learnings: ### @@ -643,10 +674,9 @@ Notes: caveats, and decision triggers; move cross-task, stable, broadly reusable user defaults to `## User preferences`. - Coverage guardrail: ensure every top-level `# Task Group` in `MEMORY.md` is represented by - at least one topic bullet in this index (either directly or via a clearly subsuming topic). -- Keep descriptions explicit: what is inside, when to use it, and what kind of - outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery), - so a future agent can quickly choose which topic/keyword cluster to search first. + at least one topic bullet in this index (either directly or via a clearly subsuming compact topic). +- Keep descriptions explicit but short: enough for a future agent to choose the right + topic/keyword cluster, not enough to replace opening `MEMORY.md`. - `memory_summary.md` should not sound like a second-order executive summary. Prefer concrete, source-faithful wording over polished abstraction, especially in: - `## User preferences` @@ -731,6 +761,9 @@ WORKFLOW ============================================================ 1. Determine mode (INIT vs INCREMENTAL UPDATE) using artifact availability and current run context. + Independently check `memory_summary.md` first line: if it is not exactly `v1`, regenerate + `memory_summary.md` from scratch after the other artifacts are finalized, even when `MEMORY.md` + itself can be updated incrementally. 2. INIT phase behavior: - Read `raw_memories.md` first, then rollout summaries carefully. @@ -747,8 +780,8 @@ WORKFLOW conflicting task families until MEMORY blocks are richer and more useful than raw memories 3. INCREMENTAL UPDATE behavior: - - Read existing `MEMORY.md` and `memory_summary.md` first for continuity and to locate - existing references that may need surgical cleanup. + - Read existing `MEMORY.md` and, only when it starts with exactly `v1`, existing + `memory_summary.md` first for continuity and to locate references that may need surgical cleanup. - Use the injected git-style workspace changes as the first routing pass: - added/modified `raw_memories.md` and `rollout_summaries/*.md` = ingestion queue - deleted `rollout_summaries/*.md` and `extensions/*/resources/*.md` = forgetting / @@ -775,6 +808,8 @@ WORKFLOW - doing light clustering and merging if needed - refreshing `MEMORY.md` top-of-file ordering so recent high-utility task families stay easy to find - rebuilding the `memory_summary.md` recent active window (last 3 memory days) from current `updated_at` coverage + - freely restructuring `memory_summary.md` so it reflects the current memory set without + stale topics, duplicated preference bullets, or obsolete routing labels - updating existing skills or adding new skills only when there is clear new reusable procedure - updating `memory_summary.md` last to reflect the final state of the memory folder - Minimize churn in incremental mode: if an existing `MEMORY.md` block or `## What's in Memory` @@ -816,6 +851,9 @@ WORKFLOW 7. Final pass: - remove duplication in memory_summary, skills/, and MEMORY.md + - verify `memory_summary.md` still begins with exactly `v1` + - verify `memory_summary.md` is dense: brief high-level profile, compact actionable + preferences, compact general tips, and a routing index rather than a second handbook - remove stale or low-signal blocks that are less likely to be useful in the future - remove or rewrite blocks/task sections whose supporting rollout references point only to deleted inputs or missing rollout summary files From da14dd2adde31b7eaf2301c20f29c17d29e33898 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 18 May 2026 03:11:54 -0700 Subject: [PATCH 047/114] [codex] Add installed-plugin mention API (#22448) ## Summary - add app-server `plugin/installed` for mention-oriented plugin loading - return installed plugins plus explicitly requested install-suggestion rows - keep remote handling on installed-state data instead of the broad catalog listing path ## Why The `@` mention surface only needs plugins that are usable now, plus a small product-approved set of install suggestions. It does not need the full catalog-shaped `plugin/list` payload that the Plugins page uses. ## Validation - `just write-app-server-schema` - `just fmt` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core-plugins` - `cargo test -p codex-app-server --test all plugin_installed_` ## Notes - The package-wide `cargo test -p codex-app-server` run still hits an existing unrelated stack overflow in `in_process::tests::in_process_start_clamps_zero_channel_capacity`. - Companion webview PR: https://github.com/openai/openai/pull/915672 --- .../schema/json/ClientRequest.json | 49 ++ .../codex_app_server_protocol.schemas.json | 74 +++ .../codex_app_server_protocol.v2.schemas.json | 74 +++ .../schema/json/v2/PluginInstalledParams.json | 33 ++ .../json/v2/PluginInstalledResponse.json | 525 ++++++++++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/PluginInstalledParams.ts | 15 + .../typescript/v2/PluginInstalledResponse.ts | 7 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 14 + .../src/protocol/v2/plugin.rs | 22 + .../src/protocol/v2/tests.rs | 21 + codex-rs/app-server/README.md | 5 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/plugins.rs | 253 ++++++++- .../app-server/tests/common/mcp_process.rs | 10 + .../app-server/tests/suite/v2/plugin_list.rs | 303 +++++++++- codex-rs/core-plugins/src/manager.rs | 36 +- codex-rs/core-plugins/src/manager_tests.rs | 127 +++-- codex-rs/core-plugins/src/remote.rs | 111 +++- .../remote/remote_installed_plugin_sync.rs | 2 +- 22 files changed, 1609 insertions(+), 82 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ad45893bbe..9c2415678f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1589,6 +1589,31 @@ ], "type": "object" }, + "PluginInstalledParams": { + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, "PluginListMarketplaceKind": { "enum": [ "local", @@ -4638,6 +4663,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { 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 8bb77fae37..94abf054db 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 @@ -757,6 +757,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -11825,6 +11849,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/v2/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { 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 01a8d6a820..601e9dd7fb 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 @@ -1464,6 +1464,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -8374,6 +8398,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json new file mode 100644 index 0000000000..f3ec95a5da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json new file mode 100644 index 0000000000..ffe20e8c55 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -0,0 +1,525 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/MarketplaceInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 28371c7106..7f017ed700 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,6 +43,7 @@ import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; +import type { PluginInstalledParams } from "./v2/PluginInstalledParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams"; @@ -80,4 +81,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts new file mode 100644 index 0000000000..83a564921a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts @@ -0,0 +1,15 @@ +// 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. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInstalledParams = { +/** + * Optional working directories used to discover repo marketplaces. + */ +cwds?: Array | null, +/** + * Additional uninstalled plugin names that should be returned when present locally. + * This is used by mention surfaces that intentionally expose install entrypoints. + */ +installSuggestionPluginNames?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts new file mode 100644 index 0000000000..d9713351ab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts @@ -0,0 +1,7 @@ +// 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. +import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; +import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; + +export type PluginInstalledResponse = { marketplaces: Array, marketplaceLoadErrors: Array, }; 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 5deeccd793..dc400a6f8e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -265,6 +265,8 @@ export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginInstalledParams } from "./PluginInstalledParams"; +export type { PluginInstalledResponse } from "./PluginInstalledResponse"; export type { PluginInterface } from "./PluginInterface"; export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type { PluginListParams } from "./PluginListParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1ab2743d6b..a08de3e528 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -625,6 +625,11 @@ client_request_definitions! { serialization: None, response: v2::PluginListResponse, }, + PluginInstalled => "plugin/installed" { + params: v2::PluginInstalledParams, + serialization: None, + response: v2::PluginInstalledResponse, + }, PluginRead => "plugin/read" { params: v2::PluginReadParams, serialization: None, @@ -1719,6 +1724,15 @@ mod tests { }; assert_eq!(plugin_read.serialization_scope(), None); + let plugin_installed = ClientRequest::PluginInstalled { + request_id: request_id(), + params: v2::PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }, + }; + assert_eq!(plugin_installed.serialization_scope(), None); + let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), params: v2::PluginUninstallParams { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 3ffab53218..33c2670077 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -125,6 +125,19 @@ pub struct PluginListParams { pub marketplace_kinds: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledParams { + /// Optional working directories used to discover repo marketplaces. + #[ts(optional = nullable)] + pub cwds: Option>, + /// Additional uninstalled plugin names that should be returned when present locally. + /// This is used by mention surfaces that intentionally expose install entrypoints. + #[ts(optional = nullable)] + pub install_suggestion_plugin_names: Option>, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[ts(export_to = "v2/")] pub enum PluginListMarketplaceKind { @@ -150,6 +163,15 @@ pub struct PluginListResponse { pub featured_plugin_ids: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index fb2a59bded..40ec247338 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2790,6 +2790,27 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { ); } +#[test] +fn plugin_installed_params_serializes_install_suggestion_names() { + assert_eq!( + serde_json::to_value(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: Some(vec![ + "computer-use".to_string(), + "chrome".to_string(), + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "installSuggestionPluginNames": [ + "computer-use", + "chrome", + ], + }), + ); +} + #[test] fn plugin_read_params_serialization_uses_install_source_fields() { let marketplace_path = if cfg!(windows) { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9d0a509c4c..d0f423d5c7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -198,6 +198,7 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. @@ -718,7 +719,7 @@ Invoke an app by including `$` in the text input and adding a `mention ### Example: Start a turn (invoke a plugin) -Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/installed` or `plugin/list`. ```json { "method": "turn/start", "id": 35, "params": { @@ -1676,7 +1677,7 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` -Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fc835b82ec..4e2c6f38cc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1102,6 +1102,9 @@ impl MessageProcessor { ClientRequest::PluginList { params, .. } => { self.plugin_processor.plugin_list(params).await } + ClientRequest::PluginInstalled { params, .. } => { + self.plugin_processor.plugin_installed(params).await + } ClientRequest::PluginRead { params, .. } => { self.plugin_processor.plugin_read(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index d5853b56c5..e941caca09 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -107,6 +107,8 @@ use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index c290d2091a..199ddf1191 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; +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; @@ -122,6 +123,39 @@ fn share_context_for_source( } } +fn convert_configured_marketplace_plugin_to_plugin_summary( + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> PluginSummary { + let share_context = share_context_for_source(&plugin.source, shared_plugin_ids_by_local_path); + PluginSummary { + id: plugin.id, + remote_plugin_id: None, + local_version: plugin.local_version, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin.interface.map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } +} + +fn remote_installed_plugin_visible_scopes(config: &Config) -> Vec { + let mut scopes = Vec::new(); + if config.features.enabled(Feature::RemotePlugin) { + scopes.push(RemotePluginScope::Global); + } + if config.features.enabled(Feature::PluginSharing) { + scopes.push(RemotePluginScope::Workspace); + } + scopes +} + fn remote_plugin_share_discoverability( discoverability: PluginShareDiscoverability, ) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { @@ -268,6 +302,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_installed( + &self, + params: PluginInstalledParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_installed_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_read( &self, params: PluginReadParams, @@ -487,27 +530,10 @@ impl PluginRequestProcessor { .plugins .into_iter() .map(|plugin| { - let share_context = share_context_for_source( - &plugin.source, + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, &shared_plugin_ids_by_local_path, - ); - PluginSummary { - id: plugin.id, - remote_plugin_id: None, - local_version: plugin.local_version, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - share_context, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: plugin - .interface - .map(local_plugin_interface_to_info), - keywords: plugin.keywords, - } + ) }) .collect(), }) @@ -632,6 +658,193 @@ impl PluginRequestProcessor { }) } + async fn plugin_installed_response( + &self, + params: PluginInstalledParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginInstalledParams { + cwds, + install_suggestion_plugin_names, + } = params; + let roots = cwds.unwrap_or_default(); + let install_suggestion_plugin_names = install_suggestion_plugin_names + .unwrap_or_default() + .into_iter() + .collect::>(); + + let empty_response = || PluginInstalledResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + + let plugins_input = config.plugins_config_input(); + let remote_installed_plugin_visible_scopes = + remote_installed_plugin_visible_scopes(&config); + plugins_manager.maybe_start_remote_installed_plugin_bundle_sync( + &plugins_input, + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + + let (mut data, marketplace_load_errors) = self + .load_local_installed_and_suggested_plugins( + plugins_manager.clone(), + &config, + &plugins_input, + roots, + install_suggestion_plugin_names, + ) + .await?; + + data.extend( + self.load_remote_installed_plugins( + plugins_manager, + &plugins_input, + &remote_installed_plugin_visible_scopes, + auth.as_ref(), + ) + .await, + ); + + Ok(PluginInstalledResponse { + marketplaces: data, + marketplace_load_errors, + }) + } + + async fn load_local_installed_and_suggested_plugins( + &self, + plugins_manager: Arc, + config: &Config, + plugins_input: &codex_core_plugins::PluginsConfigInput, + roots: Vec, + install_suggestion_plugin_names: HashSet, + ) -> Result< + ( + Vec, + Vec, + ), + JSONRPCErrorError, + > { + let config_for_marketplace_listing = plugins_input.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 + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .filter_map(|marketplace| { + let plugins = marketplace + .plugins + .into_iter() + .filter(|plugin| { + plugin.installed + || install_suggestion_plugin_names.contains(&plugin.name) + }) + .map(|plugin| { + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, + &shared_plugin_ids_by_local_path, + ) + }) + .collect::>(); + + (!plugins.is_empty()).then_some(PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins, + }) + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => Ok(outcome), + Ok(Err(err)) => Err(Self::marketplace_error( + err, + "list installed and suggested marketplace plugins", + )), + Err(err) => Err(internal_error(format!( + "failed to list installed and suggested plugins: {err}" + ))), + } + } + + async fn load_remote_installed_plugins( + &self, + plugins_manager: Arc, + plugins_input: &codex_core_plugins::PluginsConfigInput, + visible_scopes: &[RemotePluginScope], + auth: Option<&CodexAuth>, + ) -> Vec { + let remote_marketplaces = if let Some(remote_marketplaces) = + plugins_manager.build_remote_installed_plugin_marketplaces_from_cache(visible_scopes) + { + Ok(remote_marketplaces) + } else { + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + plugins_input, + auth, + visible_scopes, + Some(self.effective_plugins_changed_callback()), + ) + .await + }; + + match remote_marketplaces { + Ok(remote_marketplaces) => remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + .collect(), + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => Vec::new(), + Err(err) => { + warn!( + error = %err, + "plugin/installed remote installed plugin fetch failed; returning local marketplaces only" + ); + Vec::new() + } + } + } + async fn plugin_read_response( &self, params: PluginReadParams, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 981c22f625..3445010a7f 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -57,6 +57,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginSkillReadParams; @@ -684,6 +685,15 @@ impl McpProcess { self.send_request("plugin/list", params).await } + /// Send a `plugin/installed` JSON-RPC request. + pub async fn send_plugin_installed_request( + &mut self, + params: PluginInstalledParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/installed", params).await + } + /// Send a `plugin/read` JSON-RPC request. pub async fn send_plugin_read_request( &mut self, 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 4ffa3893d0..d439a4585b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,6 +9,8 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -125,6 +127,95 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()> +{ + let codex_home = TempDir::new()?; + write_openai_curated_marketplace( + codex_home.path(), + &["linear", "computer-use", "not-mentioned"], + )?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::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: Some(vec!["computer-use".to_string()]), + }) + .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); + assert_eq!(response.marketplaces[0].name, "openai-curated"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("computer-use@openai-curated".to_string(), false, false), + ] + ); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::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, Vec::new()); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + #[tokio::test] async fn plugin_list_rejects_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; @@ -1360,7 +1451,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_plugins_enabled_config_with_base_url( + write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; @@ -1710,6 +1801,185 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_remote_shared_with_me_plugins() -> 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 = true +"#, + 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_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; + let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::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-shared-with-me"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!( + marketplace + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ( + "shared-linear@workspace-shared-with-me".to_string(), + true, + true + ), + ( + "unlisted-linear@workspace-shared-with-me".to_string(), + true, + false + ) + ] + ); + 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()?; + 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 = true +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 bundle_url = mount_remote_plugin_bundle( + &server, + "linear", + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + let global_installed_body = + remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let plugin_installed_request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: PluginInstalledResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(plugin_installed_request_id)), + ) + .await??, + )?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "chatgpt-global"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@chatgpt-global".to_string(), true, true)] + ); + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json"); + wait_for_path_exists(&installed_path).await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { let codex_home = TempDir::new()?; @@ -1987,12 +2257,8 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) ); - wait_for_remote_plugin_request_count( - &server, - "/ps/plugins/installed", - /*expected_count*/ 5, - ) - .await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } @@ -2490,6 +2756,29 @@ async fn wait_for_remote_plugin_request_count( Ok(()) } +async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + if requests.iter().any(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/installed") + && request + .url + .query_pairs() + .any(|(name, value)| name == "scope" && value == scope) + }) { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index f6d5a6d88a..56b8c18bda 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -37,6 +37,7 @@ 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; @@ -597,6 +598,39 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub fn build_remote_installed_plugin_marketplaces_from_cache( + &self, + visible_scopes: &[RemotePluginScope], + ) -> 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)) + } + + pub async fn build_and_cache_remote_installed_plugin_marketplaces( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + visible_scopes: &[RemotePluginScope], + on_effective_plugins_changed: Option>, + ) -> Result, RemotePluginCatalogError> { + let plugins = crate::remote::fetch_remote_installed_plugins( + &remote_plugin_service_config(config), + auth, + ) + .await?; + let marketplaces = + crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes); + 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(); + } + Ok(marketplaces) + } + fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, @@ -674,7 +708,7 @@ impl PluginsManager { ); } - fn maybe_start_remote_installed_plugin_bundle_sync( + pub fn maybe_start_remote_installed_plugin_bundle_sync( self: &Arc, config: &PluginsConfigInput, auth: Option, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 449c87b186..791a143c4f 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -7,6 +7,7 @@ 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::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; @@ -147,6 +148,20 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { load_plugins_config_input(codex_home, cwd).await } +fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { + RemoteInstalledPlugin { + marketplace_name: "chatgpt-global".to_string(), + id: "plugins~Plugin_linear".to_string(), + name: "linear".to_string(), + enabled: true, + install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, + auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + } +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -334,38 +349,6 @@ approval_mode = "approve" ); } -#[tokio::test] -async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() { - let codex_home = TempDir::new().unwrap(); - let plugin_base = codex_home - .path() - .join("plugins/cache/chatgpt-global/linear"); - write_plugin(&plugin_base, "local", "linear"); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let config = load_config(codex_home.path(), codex_home.path()).await; - let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); - - let outcome = manager.plugins_for_config(&config).await; - assert_eq!( - outcome.effective_skill_roots(), - vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()] - ); - assert_eq!(outcome.plugins().len(), 1); - assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global"); -} - #[tokio::test] async fn remote_installed_cache_ignores_plugins_missing_local_cache() { let codex_home = TempDir::new().unwrap(); @@ -379,17 +362,85 @@ remote_plugin = true let config = load_config(codex_home.path(), codex_home.path()).await; let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); + manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let outcome = manager.plugins_for_config(&config).await; assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mut plugin = remote_installed_linear_plugin(); + plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault; + plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall; + plugin.interface = Some(codex_app_server_protocol::PluginInterface { + display_name: Some("Linear".to_string()), + short_description: Some("Track remote work".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: Some("#111111".to_string()), + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + }); + plugin.keywords = vec!["issues".to_string()]; + manager.write_remote_installed_plugins_cache(vec![plugin]); + + let marketplaces = manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + .expect("remote installed cache should be present"); + assert_eq!(marketplaces.len(), 1); + assert_eq!(marketplaces[0].name, "chatgpt-global"); + assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins"); + assert_eq!(marketplaces[0].plugins.len(), 1); + let plugin = &marketplaces[0].plugins[0]; + assert_eq!(plugin.id, "linear@chatgpt-global"); + assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); + assert_eq!(plugin.name, "linear"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.install_policy, + codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault + ); + assert_eq!( + plugin.auth_policy, + codex_app_server_protocol::PluginAuthPolicy::OnInstall + ); + assert_eq!(plugin.keywords, vec!["issues".to_string()]); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()), + Some("Track remote work") + ); + assert_eq!( + manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace]) + .expect("remote installed cache should be present"), + Vec::new() + ); +} + #[tokio::test] async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4dde224039..56427473a1 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -27,7 +27,7 @@ pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError; pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome; pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; -pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; +pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; pub use share::RemotePluginShareAccessPolicy; pub use share::RemotePluginShareDiscoverability; @@ -63,6 +63,28 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ + ( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + ), +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { @@ -89,6 +111,11 @@ pub struct RemoteInstalledPlugin { pub id: String, pub name: String, pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub availability: PluginAvailability, + pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -264,7 +291,7 @@ pub enum RemotePluginCatalogError { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] -enum RemotePluginScope { +pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, #[serde(rename = "WORKSPACE")] @@ -608,13 +635,7 @@ fn build_remote_marketplace( }) .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) .collect::, _>>()?; - plugins.sort_by(|left, right| { - remote_plugin_display_name(left) - .to_ascii_lowercase() - .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) - .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) - .then_with(|| left.id.cmp(&right.id)) - }); + sort_remote_plugin_summaries_by_display_name(&mut plugins); Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), @@ -622,7 +643,7 @@ fn build_remote_marketplace( })) } -pub async fn fetch_remote_installed_plugins( +pub(crate) async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result, RemotePluginCatalogError> { @@ -642,7 +663,7 @@ pub async fn fetch_remote_installed_plugins( let mut installed_plugins = [global, workspace] .into_iter() .flat_map(|(_scope, plugins)| plugins) - .map(|plugin| remote_installed_plugin_to_info(&plugin)) + .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) .collect::, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name @@ -652,6 +673,55 @@ pub async fn fetch_remote_installed_plugins( Ok(installed_plugins) } +pub fn group_remote_installed_plugins_by_marketplaces( + plugins: &[RemoteInstalledPlugin], + visible_scopes: &[RemotePluginScope], +) -> 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)) + { + continue; + } + let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) + else { + continue; + }; + let plugin_summary = RemotePluginSummary { + id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), + name: plugin.name.clone(), + share_context: None, + installed: true, + enabled: plugin.enabled, + install_policy: plugin.install_policy, + auth_policy: plugin.auth_policy, + availability: plugin.availability, + interface: plugin.interface.clone(), + keywords: plugin.keywords.clone(), + }; + plugins_by_marketplace + .entry(plugin.marketplace_name.clone()) + .or_default() + .push(plugin_summary); + } + + REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER + .into_iter() + .filter_map(|(marketplace_name, display_name)| { + let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?; + sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins); + Some(RemoteMarketplace { + name: marketplace_name.to_string(), + display_name: display_name.to_string(), + plugins: marketplace_plugins, + }) + }) + .collect() +} + pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -982,7 +1052,7 @@ fn remote_plugin_share_context( } } -fn remote_installed_plugin_to_info( +fn remote_installed_plugin_to_cache_entry( installed_plugin: &RemotePluginInstalledItem, ) -> Result { let plugin = &installed_plugin.plugin; @@ -994,6 +1064,11 @@ fn remote_installed_plugin_to_info( id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + availability: plugin.availability, + interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), }) } @@ -1068,6 +1143,18 @@ fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { .unwrap_or(&plugin.name) } +fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) { + plugins.sort_by(|left, right| { + let left_display_name = remote_plugin_display_name(left); + let right_display_name = remote_plugin_display_name(right); + left_display_name + .to_ascii_lowercase() + .cmp(&right_display_name.to_ascii_lowercase()) + .then_with(|| left_display_name.cmp(right_display_name)) + .then_with(|| left.id.cmp(&right.id)) + }); +} + fn non_empty_string(value: Option<&str>) -> Option { value.and_then(|value| { let value = value.trim(); diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index b13ca7a630..69cbfe2569 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -78,7 +78,7 @@ pub struct RemotePluginCacheMutationGuard { key: RemotePluginCacheMutationKey, } -pub fn maybe_start_remote_installed_plugin_bundle_sync( +pub(crate) fn maybe_start_remote_installed_plugin_bundle_sync( codex_home: PathBuf, config: RemotePluginServiceConfig, auth: Option, From a80f07ec4aa9dd311091b91d41945043765a1caa Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 13:32:21 +0200 Subject: [PATCH 048/114] chore: goal ext skeleton (#23288) Skeleton of `/goal` in extension Lot's of follow-ups coming --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 3 + codex-rs/ext/goal/BUILD.bazel | 6 + codex-rs/ext/goal/Cargo.toml | 22 +++ codex-rs/ext/goal/src/accounting.rs | 73 ++++++++ codex-rs/ext/goal/src/extension.rs | 278 ++++++++++++++++++++++++++++ codex-rs/ext/goal/src/lib.rs | 21 +++ codex-rs/ext/goal/src/spec.rs | 89 +++++++++ codex-rs/ext/goal/src/tool.rs | 231 +++++++++++++++++++++++ 9 files changed, 735 insertions(+) create mode 100644 codex-rs/ext/goal/BUILD.bazel create mode 100644 codex-rs/ext/goal/Cargo.toml create mode 100644 codex-rs/ext/goal/src/accounting.rs create mode 100644 codex-rs/ext/goal/src/extension.rs create mode 100644 codex-rs/ext/goal/src/lib.rs create mode 100644 codex-rs/ext/goal/src/spec.rs create mode 100644 codex-rs/ext/goal/src/tool.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 83ca0d1191..850ddbd525 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2946,6 +2946,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-goal-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-extension-api", + "codex-protocol", + "codex-tools", + "serde", + "serde_json", +] + [[package]] name = "codex-guardian" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7207fd3aba..efc64577dd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -45,6 +45,7 @@ members = [ "execpolicy", "execpolicy-legacy", "ext/extension-api", + "ext/goal", "ext/guardian", "ext/memories", "external-agent-migration", @@ -162,6 +163,7 @@ codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } +codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } @@ -470,6 +472,7 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", + "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/ext/goal/BUILD.bazel b/codex-rs/ext/goal/BUILD.bazel new file mode 100644 index 0000000000..037313da37 --- /dev/null +++ b/codex-rs/ext/goal/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "goal", + crate_name = "codex_goal_extension", +) diff --git a/codex-rs/ext/goal/Cargo.toml b/codex-rs/ext/goal/Cargo.toml new file mode 100644 index 0000000000..7f8b9bd308 --- /dev/null +++ b/codex-rs/ext/goal/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-goal-extension" +version.workspace = true + +[lib] +name = "codex_goal_extension" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-extension-api = { workspace = true } +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs new file mode 100644 index 0000000000..712e325849 --- /dev/null +++ b/codex-rs/ext/goal/src/accounting.rs @@ -0,0 +1,73 @@ +use codex_protocol::protocol::TokenUsage; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::PoisonError; + +#[derive(Debug, Default)] +pub(crate) struct GoalAccountingState { + inner: Mutex, +} + +#[derive(Debug, Default)] +struct GoalAccountingInner { + turns: HashMap, + unflushed_token_delta: i64, +} + +#[derive(Debug, Default)] +struct GoalTurnAccounting { + token_delta: i64, + stopped: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RecordedTokenDelta { + pub(crate) turn_delta: i64, + pub(crate) thread_unflushed_delta: i64, +} + +impl GoalAccountingState { + pub(crate) fn start_turn(&self, turn_id: impl Into) { + let turn_id = turn_id.into(); + self.inner().turns.entry(turn_id).or_default().stopped = false; + } + + pub(crate) fn record_token_usage( + &self, + turn_id: impl Into, + usage: &TokenUsage, + ) -> Option { + let delta = goal_token_delta_for_usage(usage); + if delta <= 0 { + return None; + } + + let turn_id = turn_id.into(); + let mut inner = self.inner(); + let turn = inner.turns.entry(turn_id).or_default(); + turn.token_delta = turn.token_delta.saturating_add(delta); + let turn_delta = turn.token_delta; + inner.unflushed_token_delta = inner.unflushed_token_delta.saturating_add(delta); + Some(RecordedTokenDelta { + turn_delta, + thread_unflushed_delta: inner.unflushed_token_delta, + }) + } + + pub(crate) fn stop_turn(&self, turn_id: &str) { + if let Some(turn) = self.inner().turns.get_mut(turn_id) { + turn.stopped = true; + } + } + + fn inner(&self) -> std::sync::MutexGuard<'_, GoalAccountingInner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { + usage + .input_tokens + .saturating_sub(usage.cached_input_tokens) + .saturating_add(usage.output_tokens.max(0)) +} diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs new file mode 100644 index 0000000000..0d78742962 --- /dev/null +++ b/codex-rs/ext/goal/src/extension.rs @@ -0,0 +1,278 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TokenUsageContributor; +use codex_extension_api::ToolContributor; +use codex_extension_api::TurnAbortInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStartInput; +use codex_extension_api::TurnStopInput; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortReason; + +use crate::accounting::GoalAccountingState; +use crate::tool::CreateGoalRequest; +use crate::tool::GoalToolExecutor; + +#[derive(Clone, Debug)] +pub struct GoalExtensionConfig { + pub enabled: bool, +} + +impl GoalExtensionConfig { + fn from_enabled(enabled: bool) -> Self { + Self { enabled } + } +} + +#[derive(Clone)] +pub struct GoalExtension { + backend: Arc, + goals_enabled: Arc bool + Send + Sync>, +} + +impl std::fmt::Debug for GoalExtension { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GoalExtension").finish_non_exhaustive() + } +} + +impl GoalExtension { + pub fn new( + backend: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, + ) -> Self { + Self { + backend, + goals_enabled: Arc::new(goals_enabled), + } + } + + pub fn without_backend(goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static) -> Self { + Self::new(Arc::new(NoGoalToolBackend), goals_enabled) + } +} + +#[async_trait] +pub trait GoalToolBackend: Send + Sync { + async fn get_goal(&self, thread_id: ThreadId) -> Result, String>; + + async fn create_goal( + &self, + thread_id: ThreadId, + request: CreateGoalRequest, + ) -> Result; + + async fn complete_goal(&self, thread_id: ThreadId) -> Result; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct NoGoalToolBackend; + +#[async_trait] +impl GoalToolBackend for NoGoalToolBackend { + async fn get_goal(&self, _thread_id: ThreadId) -> Result, String> { + Err(missing_backend_message()) + } + + async fn create_goal( + &self, + _thread_id: ThreadId, + _request: CreateGoalRequest, + ) -> Result { + Err(missing_backend_message()) + } + + async fn complete_goal(&self, _thread_id: ThreadId) -> Result { + Err(missing_backend_message()) + } +} + +fn missing_backend_message() -> String { + // TODO: replace this fallback with a host-provided goal backend once + // ToolContributor invocations can reach thread-scoped goal persistence and + // the current turn context. + "goal tools are not connected to host goal persistence yet".to_string() +} + +impl ThreadLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { + input + .thread_store + .insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( + input.config, + ))); + input + .thread_store + .get_or_init::(GoalAccountingState::default); + } +} + +impl ConfigContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &C, + new_config: &C, + ) { + thread_store.insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( + new_config, + ))); + } +} + +impl TurnLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_turn_start(&self, input: TurnStartInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + // TODO: TurnStartInput should expose collaboration mode and token usage + // at turn start. Goals need mode to suppress plan-mode accounting and + // the token baseline to account deltas exactly. + accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); + } + + fn on_turn_stop(&self, input: TurnStopInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + // TODO: this should flush wall-clock and any unflushed token usage to + // persisted goal storage, emit ThreadGoalUpdated, and optionally inject + // budget-limit steering through a host event/input capability. + // TODO: the host also needs an idle/next-turn wake capability so an + // active goal can enqueue continuation context after the turn is fully + // cleared, only when there is no pending user or mailbox work. + accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); + } + + fn on_turn_abort(&self, input: TurnAbortInput<'_>) { + if !goal_enabled(input.thread_store) { + return; + } + + accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); + if input.reason == TurnAbortReason::Interrupted { + // TODO: interrupted turns should pause the active goal via persisted + // goal storage and emit ThreadGoalUpdated with turn_id None. + } + } +} + +impl TokenUsageContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_token_usage( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + token_usage: &TokenUsageInfo, + ) { + if !goal_enabled(thread_store) { + return; + } + + let Some(_recorded) = accounting_state(thread_store) + .record_token_usage(turn_store.level_id(), &token_usage.last_token_usage) + else { + return; + }; + + // TODO: TokenUsageContributor needs a host goal storage capability so + // this recorded delta can be committed to the active persisted goal. + // It also needs an event/input capability to emit ThreadGoalUpdated and + // inject budget-limit steering when accounting changes goal status. + // TODO: if the storage/event path must await, TokenUsageContributor + // either needs to become async or receive a fire-and-forget host sink. + } +} + +// TODO: app-server initiated goal set/clear operations need a contributor or +// backend callback here. They currently happen outside thread/turn/token +// lifecycle, but the goal extension must observe them to account before +// mutation, refresh active-goal accounting, emit objective-update steering, and +// clear runtime state when a goal is removed. + +impl ToolContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + if !goal_enabled(thread_store) { + return Vec::new(); + } + + let Ok(thread_id) = ThreadId::from_string(thread_store.level_id()) else { + return Vec::new(); + }; + vec![ + Arc::new(GoalToolExecutor::get(thread_id, Arc::clone(&self.backend))), + Arc::new(GoalToolExecutor::create( + thread_id, + Arc::clone(&self.backend), + )), + Arc::new(GoalToolExecutor::update( + thread_id, + Arc::clone(&self.backend), + )), + ] + } +} + +pub fn install( + registry: &mut ExtensionRegistryBuilder, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, +) where + C: Send + Sync + 'static, +{ + install_with_backend(registry, Arc::new(NoGoalToolBackend), goals_enabled); +} + +pub fn install_with_backend( + registry: &mut ExtensionRegistryBuilder, + backend: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, +) where + C: Send + Sync + 'static, +{ + let extension = Arc::new(GoalExtension::new(backend, goals_enabled)); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension.clone()); + registry.token_usage_contributor(extension.clone()); + registry.tool_contributor(extension); +} + +fn goal_enabled(thread_store: &ExtensionData) -> bool { + thread_store + .get::() + .is_some_and(|config| config.enabled) +} + +fn accounting_state(thread_store: &ExtensionData) -> Arc { + thread_store.get_or_init::(GoalAccountingState::default) +} diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs new file mode 100644 index 0000000000..17a43e4671 --- /dev/null +++ b/codex-rs/ext/goal/src/lib.rs @@ -0,0 +1,21 @@ +//! Extension crate sketch for the `/goal` feature. +//! +//! This crate is intentionally not wired into the host yet. It contains the +//! goal tool specs, extension registration shape, and the parts of runtime +//! accounting that can be represented with today's extension API. + +mod accounting; +mod extension; +mod spec; +mod tool; + +pub use extension::GoalExtension; +pub use extension::GoalExtensionConfig; +pub use extension::GoalToolBackend; +pub use extension::NoGoalToolBackend; +pub use extension::install; +pub use extension::install_with_backend; +pub use spec::CREATE_GOAL_TOOL_NAME; +pub use spec::GET_GOAL_TOOL_NAME; +pub use spec::UPDATE_GOAL_TOOL_NAME; +pub use tool::CreateGoalRequest; diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs new file mode 100644 index 0000000000..e127846ab5 --- /dev/null +++ b/codex-rs/ext/goal/src/spec.rs @@ -0,0 +1,89 @@ +//! Responses API tool definitions for persisted thread goals. + +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; +pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; +pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; + +pub fn create_get_goal_tool() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: GET_GOAL_TOOL_NAME.to_string(), + description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), + output_schema: None, + }) +} + +pub fn create_create_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "objective".to_string(), + JsonSchema::string(Some( + "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + .to_string(), + )), + ), + ( + "token_budget".to_string(), + JsonSchema::integer(Some( + "Optional positive token budget for the new active goal.".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: CREATE_GOAL_TOOL_NAME.to_string(), + description: format!( + r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. +Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# + ), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["objective".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +pub fn create_update_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "status".to_string(), + JsonSchema::string_enum( + vec![json!("complete")], + Some( + "Required. Set to complete only when the objective is achieved and no required work remains." + .to_string(), + ), + ), + )]); + + ToolSpec::Function(ResponsesApiTool { + name: UPDATE_GOAL_TOOL_NAME.to_string(), + description: r#"Update the existing goal. +Use this tool only to mark the goal achieved. +Set status to `complete` only when the objective has actually been achieved and no required work remains. +Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. +You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["status".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs new file mode 100644 index 0000000000..d19dc81056 --- /dev/null +++ b/codex-rs/ext/goal/src/tool.rs @@ -0,0 +1,231 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use codex_extension_api::FunctionCallError; +use codex_extension_api::JsonToolOutput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolSpec; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::validate_thread_goal_objective; +use serde::Deserialize; +use serde::Serialize; + +use crate::extension::GoalToolBackend; +use crate::spec::CREATE_GOAL_TOOL_NAME; +use crate::spec::GET_GOAL_TOOL_NAME; +use crate::spec::UPDATE_GOAL_TOOL_NAME; +use crate::spec::create_create_goal_tool; +use crate::spec::create_get_goal_tool; +use crate::spec::create_update_goal_tool; + +#[derive(Clone)] +pub(crate) struct GoalToolExecutor { + kind: GoalToolKind, + thread_id: ThreadId, + backend: Arc, +} + +#[derive(Clone, Copy)] +enum GoalToolKind { + Get, + Create, + Update, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct CreateGoalRequest { + pub objective: String, + pub token_budget: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct UpdateGoalArgs { + status: ThreadGoalStatus, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GoalToolResponse { + goal: Option, + remaining_tokens: Option, + completion_budget_report: Option, +} + +#[derive(Clone, Copy)] +enum CompletionBudgetReport { + Include, + Omit, +} + +impl GoalToolExecutor { + pub(crate) fn get(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Get, + thread_id, + backend, + } + } + + pub(crate) fn create(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Create, + thread_id, + backend, + } + } + + pub(crate) fn update(thread_id: ThreadId, backend: Arc) -> Self { + Self { + kind: GoalToolKind::Update, + thread_id, + backend, + } + } +} + +#[async_trait] +impl ToolExecutor for GoalToolExecutor { + fn tool_name(&self) -> ToolName { + ToolName::plain(match self.kind { + GoalToolKind::Get => GET_GOAL_TOOL_NAME, + GoalToolKind::Create => CREATE_GOAL_TOOL_NAME, + GoalToolKind::Update => UPDATE_GOAL_TOOL_NAME, + }) + } + + fn spec(&self) -> Option { + Some(match self.kind { + GoalToolKind::Get => create_get_goal_tool(), + GoalToolKind::Create => create_create_goal_tool(), + GoalToolKind::Update => create_update_goal_tool(), + }) + } + + async fn handle(&self, invocation: ToolCall) -> Result, FunctionCallError> { + match self.kind { + GoalToolKind::Get => self.handle_get(invocation).await, + GoalToolKind::Create => self.handle_create(invocation).await, + GoalToolKind::Update => self.handle_update(invocation).await, + } + } +} + +impl GoalToolExecutor { + async fn handle_get( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let _ = invocation.function_arguments()?; + let goal = self + .backend + .get_goal(self.thread_id) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(goal, CompletionBudgetReport::Omit) + } + + async fn handle_create( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let mut request: CreateGoalRequest = parse_arguments(invocation.function_arguments()?)?; + request.objective = request.objective.trim().to_string(); + validate_thread_goal_objective(&request.objective) + .map_err(FunctionCallError::RespondToModel)?; + validate_goal_budget(request.token_budget).map_err(FunctionCallError::RespondToModel)?; + + let goal = self + .backend + .create_goal(self.thread_id, request) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(Some(goal), CompletionBudgetReport::Omit) + } + + async fn handle_update( + &self, + invocation: ToolCall, + ) -> Result, FunctionCallError> { + let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?; + if args.status != ThreadGoalStatus::Complete { + return Err(FunctionCallError::RespondToModel( + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + .to_string(), + )); + } + + // TODO: update_goal needs a host callback before completion to flush + // final active-turn accounting with budget steering suppressed. + let goal = self + .backend + .complete_goal(self.thread_id) + .await + .map_err(FunctionCallError::RespondToModel)?; + goal_response(Some(goal), CompletionBudgetReport::Include) + } +} + +fn parse_arguments(arguments: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_str(arguments) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +fn validate_goal_budget(value: Option) -> Result<(), String> { + if let Some(value) = value + && value <= 0 + { + return Err("goal budgets must be positive when provided".to_string()); + } + Ok(()) +} + +fn goal_response( + goal: Option, + completion_budget_report: CompletionBudgetReport, +) -> Result, FunctionCallError> { + let value = serde_json::to_value(GoalToolResponse::new(goal, completion_budget_report)) + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + Ok(Box::new(JsonToolOutput::new(value))) +} + +impl GoalToolResponse { + fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { + let remaining_tokens = goal.as_ref().and_then(|goal| { + goal.token_budget + .map(|budget| (budget - goal.tokens_used).max(0)) + }); + let completion_budget_report = match report_mode { + CompletionBudgetReport::Include => goal + .as_ref() + .filter(|goal| goal.status == ThreadGoalStatus::Complete) + .and_then(completion_budget_report), + CompletionBudgetReport::Omit => None, + }; + Self { + goal, + remaining_tokens, + completion_budget_report, + } + } +} + +fn completion_budget_report(goal: &ThreadGoal) -> Option { + if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { + None + } else { + Some( + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." + .to_string(), + ) + } +} From 9531e932ef33b3834e5dc23fe71ae7ac84e6e213 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 13:53:58 +0200 Subject: [PATCH 049/114] Make extension lifecycle hooks async (#23291) ## Why Extension lifecycle hooks sit on the host/extension boundary, but the current trait surface only allows synchronous callbacks. That forces extensions that need to seed, rehydrate, observe, or flush extension-owned state during thread and turn transitions to either block inside the callback or move async work into separate host plumbing. This PR makes those lifecycle callbacks awaitable so extension implementations can perform async work directly at the lifecycle point where the host already has the relevant session, thread, or turn stores available. ## What changed - Makes `ThreadLifecycleContributor` and `TurnLifecycleContributor` async in `codex-extension-api`. - Awaits thread start/resume/stop and turn start/stop/abort lifecycle callbacks from `codex-core`. - Updates the guardian and memories extensions to implement the async lifecycle trait surface. - Updates the existing lifecycle tests to use async contributor implementations. - Adds `async-trait` to the crates that now expose or implement these async object-safe lifecycle traits. ## Testing - Existing `codex-core` lifecycle tests were updated to cover async implementations for thread stop and turn abort ordering. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/codex_thread.rs | 12 ++--- codex-rs/core/src/session/handlers.rs | 16 ++++--- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 9 ++-- codex-rs/core/src/tasks/lifecycle.rs | 44 +++++++++++-------- codex-rs/core/src/tasks/mod.rs | 12 +++-- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/ext/extension-api/Cargo.toml | 1 + .../ext/extension-api/src/contributors.rs | 16 ++++--- codex-rs/ext/extension-api/src/registry.rs | 12 ++--- codex-rs/ext/goal/src/extension.rs | 10 +++-- codex-rs/ext/guardian/Cargo.toml | 1 + codex-rs/ext/guardian/src/lib.rs | 3 +- codex-rs/ext/memories/src/extension.rs | 3 +- 15 files changed, 86 insertions(+), 59 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 850ddbd525..5ced1803fa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2829,6 +2829,7 @@ dependencies = [ name = "codex-extension-api" version = "0.0.0" dependencies = [ + "async-trait", "codex-protocol", "codex-tools", ] @@ -2962,6 +2963,7 @@ dependencies = [ name = "codex-guardian" version = "0.0.0" dependencies = [ + "async-trait", "codex-core", "codex-extension-api", "codex-protocol", diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1a2d5ed710..7e9e6e38cd 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -144,7 +144,7 @@ impl CodexThread { self.codex.session_loop_termination.clone().await; } - pub(crate) fn emit_thread_resume_lifecycle(&self) { + pub(crate) async fn emit_thread_resume_lifecycle(&self) { for contributor in self .codex .session @@ -152,10 +152,12 @@ impl CodexThread { .extensions .thread_lifecycle_contributors() { - contributor.on_thread_resume(codex_extension_api::ThreadResumeInput { - session_store: &self.codex.session.services.session_extension_data, - thread_store: &self.codex.session.services.thread_extension_data, - }); + contributor + .on_thread_resume(codex_extension_api::ThreadResumeInput { + session_store: &self.codex.session.services.session_extension_data, + thread_store: &self.codex.session.services.thread_extension_data, + }) + .await; } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index a26b9bb3f6..c2ffe2f4e4 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -638,12 +638,14 @@ async fn shutdown_session_runtime(sess: &Arc) { sess.guardian_review_session.shutdown().await; } -fn emit_thread_stop_lifecycle(sess: &Session) { +async fn emit_thread_stop_lifecycle(sess: &Session) { for contributor in sess.services.extensions.thread_lifecycle_contributors() { - contributor.on_thread_stop(codex_extension_api::ThreadStopInput { - session_store: &sess.services.session_extension_data, - thread_store: &sess.services.thread_extension_data, - }); + contributor + .on_thread_stop(codex_extension_api::ThreadStopInput { + session_store: &sess.services.session_extension_data, + thread_store: &sess.services.thread_extension_data, + }) + .await; } } @@ -662,7 +664,7 @@ pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { &[], ); - emit_thread_stop_lifecycle(sess.as_ref()); + emit_thread_stop_lifecycle(sess.as_ref()).await; // Gracefully flush and shutdown thread persistence on session end so tests // that inspect durable state do not race with the background writer. @@ -917,7 +919,7 @@ pub(super) async fn submission_loop( // explicit shutdown op, still run session teardown. if !shutdown_received { shutdown_session_runtime(&sess).await; - emit_thread_stop_lifecycle(sess.as_ref()); + emit_thread_stop_lifecycle(sess.as_ref()).await; } debug!("Agent loop exited"); } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3f8276d04d..925ee2df59 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -877,7 +877,7 @@ impl Session { config: config.as_ref(), session_store: &session_extension_data, thread_store: &thread_extension_data, - }); + }).await; } let services = SessionServices { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bb858999e4..511dbfeabb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5683,8 +5683,9 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() { expected_thread_id: ThreadId, } + #[async_trait::async_trait] impl codex_extension_api::ThreadLifecycleContributor for ThreadStopRecorder { - fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() @@ -5728,8 +5729,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif expected_turn_id: String, } + #[async_trait::async_trait] impl codex_extension_api::ThreadLifecycleContributor for LifecycleRecorder { - fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { + async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() @@ -5741,8 +5743,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif } } + #[async_trait::async_trait] impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder { - fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { + async fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { assert_eq!( self.expected_thread_id.to_string(), input.thread_store.level_id() diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 2fb9017df2..94b51647c9 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -4,38 +4,44 @@ use codex_protocol::protocol::TurnAbortReason; use crate::session::session::Session; impl Session { - pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { + pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_start(codex_extension_api::TurnStartInput { - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_start(codex_extension_api::TurnStartInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } - pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { + pub(super) async fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_stop(codex_extension_api::TurnStopInput { - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_stop(codex_extension_api::TurnStopInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } - pub(super) fn emit_turn_abort_lifecycle( + pub(super) async fn emit_turn_abort_lifecycle( &self, reason: TurnAbortReason, turn_store: &ExtensionData, ) { for contributor in self.services.extensions.turn_lifecycle_contributors() { - contributor.on_turn_abort(codex_extension_api::TurnAbortInput { - reason: reason.clone(), - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - turn_store, - }); + contributor + .on_turn_abort(codex_extension_api::TurnAbortInput { + reason: reason.clone(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store, + }) + .await; } } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 58ead0fd25..7724d5f2d7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -365,7 +365,8 @@ impl Session { turn_state.push_pending_input(item); } } - self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()); + self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()) + .await; let turn_extension_data = Arc::clone(&turn_context.extension_data); let mut active = self.active_turn.lock().await; @@ -505,7 +506,8 @@ impl Session { } if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; } if (aborted_turn || reason == TurnAbortReason::Interrupted) && let Err(err) = self @@ -553,7 +555,8 @@ impl Session { self.handle_task_abort(task, reason.clone()).await; } if let Some(turn_context) = turn_context.as_deref() { - self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { @@ -756,7 +759,8 @@ impl Session { .time_to_first_token_ms() .await; if should_clear_active_turn { - self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()); + self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) + .await; } if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7b3d0a9921..e5b4fcf6a2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1228,7 +1228,7 @@ impl ThreadManagerState { .finalize_thread_spawn(codex, thread_id, tracked_session_source) .await?; if is_resumed_thread { - new_thread.thread.emit_thread_resume_lifecycle(); + new_thread.thread.emit_thread_resume_lifecycle().await; if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { warn!("failed to apply goal resume runtime effects: {err}"); } diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index ceecc48023..15f6e99fa0 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -14,5 +14,6 @@ doctest = false workspace = true [dependencies] +async-trait = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index a8c2001200..b03846fd31 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -36,16 +36,17 @@ pub trait ContextContributor: Send + Sync { /// Implementations should use these callbacks to seed, rehydrate, or flush /// extension-private thread state. Heavy dependencies belong on the extension /// value created by the host, not in these inputs. -pub trait ThreadLifecycleContributor: Send + Sync { +#[async_trait::async_trait] +pub trait ThreadLifecycleContributor: Send + Sync { /// Called after thread-scoped extension stores are created, before later /// contributors can read from them. - fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} + async fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} /// Called after the host constructs a runtime from persisted history. - fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} /// Called before the host drops the thread runtime and thread-scoped store. - fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} + async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} } /// Contributor for host-owned turn lifecycle gates. @@ -53,16 +54,17 @@ pub trait ThreadLifecycleContributor: Send + Sync { /// Implementations should use these callbacks to seed, observe, or clear /// extension-private turn state. The host exposes stable identifiers and /// extension stores instead of core runtime objects. +#[async_trait::async_trait] pub trait TurnLifecycleContributor: Send + Sync { /// Called after turn-scoped extension stores are created, before the task /// for the turn starts running. - fn on_turn_start(&self, _input: TurnStartInput<'_>) {} + async fn on_turn_start(&self, _input: TurnStartInput<'_>) {} /// Called before the host drops the completed turn runtime and turn store. - fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} + async fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} /// Called after the host aborts a running turn. - fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} + async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} } /// Contributor for host-owned configuration changes. diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 8193f5c1d1..f00e996315 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -12,7 +12,7 @@ use crate::TurnItemContributor; use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. -pub struct ExtensionRegistryBuilder { +pub struct ExtensionRegistryBuilder { thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -23,7 +23,7 @@ pub struct ExtensionRegistryBuilder { approval_review_contributors: Vec>, } -impl Default for ExtensionRegistryBuilder { +impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { thread_lifecycle_contributors: Vec::new(), @@ -38,7 +38,7 @@ impl Default for ExtensionRegistryBuilder { } } -impl ExtensionRegistryBuilder { +impl ExtensionRegistryBuilder { /// Creates an empty registry builder. pub fn new() -> Self { Self::default() @@ -103,7 +103,7 @@ impl ExtensionRegistryBuilder { } /// Immutable typed registry produced after extensions are installed. -pub struct ExtensionRegistry { +pub struct ExtensionRegistry { thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -114,7 +114,7 @@ pub struct ExtensionRegistry { approval_review_contributors: Vec>, } -impl ExtensionRegistry { +impl ExtensionRegistry { /// Returns the registered thread-lifecycle contributors. pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { &self.thread_lifecycle_contributors @@ -165,6 +165,6 @@ impl ExtensionRegistry { } /// Creates an empty shared registry for hosts that do not register contributions. -pub fn empty_extension_registry() -> Arc> { +pub fn empty_extension_registry() -> Arc> { Arc::new(ExtensionRegistryBuilder::new().build()) } diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 0d78742962..97d7157298 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -102,11 +102,12 @@ fn missing_backend_message() -> String { "goal tools are not connected to host goal persistence yet".to_string() } +#[async_trait] impl ThreadLifecycleContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { input .thread_store .insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( @@ -135,11 +136,12 @@ where } } +#[async_trait] impl TurnLifecycleContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_turn_start(&self, input: TurnStartInput<'_>) { + async fn on_turn_start(&self, input: TurnStartInput<'_>) { if !goal_enabled(input.thread_store) { return; } @@ -150,7 +152,7 @@ where accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); } - fn on_turn_stop(&self, input: TurnStopInput<'_>) { + async fn on_turn_stop(&self, input: TurnStopInput<'_>) { if !goal_enabled(input.thread_store) { return; } @@ -164,7 +166,7 @@ where accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); } - fn on_turn_abort(&self, input: TurnAbortInput<'_>) { + async fn on_turn_abort(&self, input: TurnAbortInput<'_>) { if !goal_enabled(input.thread_store) { return; } diff --git a/codex-rs/ext/guardian/Cargo.toml b/codex-rs/ext/guardian/Cargo.toml index 513254b7cf..53e553ec1c 100644 --- a/codex-rs/ext/guardian/Cargo.toml +++ b/codex-rs/ext/guardian/Cargo.toml @@ -14,6 +14,7 @@ doctest = false workspace = true [dependencies] +async-trait = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs index 809a381471..0591887c25 100644 --- a/codex-rs/ext/guardian/src/lib.rs +++ b/codex-rs/ext/guardian/src/lib.rs @@ -47,11 +47,12 @@ impl GuardianThreadContext { } } +#[async_trait::async_trait] impl ThreadLifecycleContributor for GuardianExtension where S: Send + Sync, { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { let Ok(forked_from_thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { return; }; diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 1a52eb2cb9..a50949e919 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -58,8 +58,9 @@ impl ContextContributor for MemoriesExtension { } } +#[async_trait::async_trait] impl ThreadLifecycleContributor for MemoriesExtension { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { input .thread_store .insert(MemoriesExtensionConfig::from_config(input.config)); From 6a8173588c15a519f3bb4e63f0cf5ab392f18053 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 14:08:56 +0200 Subject: [PATCH 050/114] feat: add extension event sink capability (#23293) ## Why Extensions can already expose typed contributions and receive host capabilities such as `AgentSpawner`, but they do not have a typed way to send protocol events back through the host. Extensions that need to surface progress or status should not have to own persistence, ordering, transport fanout, or logging decisions themselves. ## What - Add `ExtensionEventSink`, a host-provided fire-and-forget sink for `codex_protocol::protocol::Event`. - Add `NoopExtensionEventSink` so hosts that do not expose extension event emission keep the existing empty-registry behavior. - Store the sink on `ExtensionRegistryBuilder` / `ExtensionRegistry`, with `with_event_sink(...)` and `event_sink()` accessors, and re-export the new capability from `codex-extension-api`. ## Testing - Not run locally; PR metadata/body update only. --- .../extension-api/src/capabilities/events.rs | 19 +++++++++++++++ .../ext/extension-api/src/capabilities/mod.rs | 3 +++ codex-rs/ext/extension-api/src/lib.rs | 2 ++ codex-rs/ext/extension-api/src/registry.rs | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 codex-rs/ext/extension-api/src/capabilities/events.rs diff --git a/codex-rs/ext/extension-api/src/capabilities/events.rs b/codex-rs/ext/extension-api/src/capabilities/events.rs new file mode 100644 index 0000000000..d8fda1aea7 --- /dev/null +++ b/codex-rs/ext/extension-api/src/capabilities/events.rs @@ -0,0 +1,19 @@ +use codex_protocol::protocol::Event; + +/// Host-provided fire-and-forget sink for extension-generated events. +/// +/// Extensions construct protocol events with the correlation id appropriate for +/// the callback they are handling, then leave persistence, ordering, transport +/// fanout, and logging decisions to the host. +pub trait ExtensionEventSink: Send + Sync { + /// Queue one protocol event for host-owned delivery. + fn emit(&self, event: Event); +} + +/// Event sink used when the host does not expose extension event emission. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopExtensionEventSink; + +impl ExtensionEventSink for NoopExtensionEventSink { + fn emit(&self, _event: Event) {} +} diff --git a/codex-rs/ext/extension-api/src/capabilities/mod.rs b/codex-rs/ext/extension-api/src/capabilities/mod.rs index bcc33da975..cd23146542 100644 --- a/codex-rs/ext/extension-api/src/capabilities/mod.rs +++ b/codex-rs/ext/extension-api/src/capabilities/mod.rs @@ -1,4 +1,7 @@ mod agent; +mod events; pub use agent::AgentSpawnFuture; pub use agent::AgentSpawner; +pub use events::ExtensionEventSink; +pub use events::NoopExtensionEventSink; diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index e8be7e0309..fe33d42128 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -5,6 +5,8 @@ mod state; pub use capabilities::AgentSpawnFuture; pub use capabilities::AgentSpawner; +pub use capabilities::ExtensionEventSink; +pub use capabilities::NoopExtensionEventSink; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index f00e996315..41d0967126 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -5,6 +5,8 @@ use crate::ApprovalReviewFuture; use crate::ConfigContributor; use crate::ContextContributor; use crate::ExtensionData; +use crate::ExtensionEventSink; +use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; @@ -13,6 +15,7 @@ use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. pub struct ExtensionRegistryBuilder { + event_sink: Arc, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -26,6 +29,7 @@ pub struct ExtensionRegistryBuilder { impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { + event_sink: Arc::new(NoopExtensionEventSink), thread_lifecycle_contributors: Vec::new(), turn_lifecycle_contributors: Vec::new(), config_contributors: Vec::new(), @@ -44,6 +48,19 @@ impl ExtensionRegistryBuilder { Self::default() } + /// Creates an empty registry builder with a host-provided event sink. + pub fn with_event_sink(event_sink: Arc) -> Self { + Self { + event_sink, + ..Self::default() + } + } + + /// Returns the host event sink to pass into extension constructors. + pub fn event_sink(&self) -> Arc { + Arc::clone(&self.event_sink) + } + /// Registers one approval-review contributor. pub fn approval_review_contributor(&mut self, contributor: Arc) { self.approval_review_contributors.push(contributor); @@ -90,6 +107,7 @@ impl ExtensionRegistryBuilder { /// Finishes construction and returns the immutable registry. pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { + event_sink: self.event_sink, thread_lifecycle_contributors: self.thread_lifecycle_contributors, turn_lifecycle_contributors: self.turn_lifecycle_contributors, config_contributors: self.config_contributors, @@ -104,6 +122,7 @@ impl ExtensionRegistryBuilder { /// Immutable typed registry produced after extensions are installed. pub struct ExtensionRegistry { + event_sink: Arc, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -115,6 +134,11 @@ pub struct ExtensionRegistry { } impl ExtensionRegistry { + /// Returns the host event sink retained by this registry. + pub fn event_sink(&self) -> Arc { + Arc::clone(&self.event_sink) + } + /// Returns the registered thread-lifecycle contributors. pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { &self.thread_lifecycle_contributors From 7ee7fe239f8bd2f478a30c369c2566004769a3da Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 14:47:05 +0200 Subject: [PATCH 051/114] chore: isolate thread goal storage behind GoalStore (#23295) ## Why Thread goal persistence is being prepared for a dedicated storage boundary. Before that split, goal-specific reads, writes, accounting, and cleanup were exposed directly on `StateRuntime`, so core and app-server callsites stayed coupled to the full runtime instead of a goal-specific store. This PR introduces that boundary without changing the goal wire API or current persistence behavior. Callers now go through `StateRuntime::thread_goals()` and the new `GoalStore`, while `GoalStore` still uses the existing state DB pool underneath. ## What changed - Added `GoalStore` in `state/src/runtime/goals.rs` and exposed it from `StateRuntime` via `thread_goals()`. - Moved thread-goal reads, writes, status updates, pause, delete, and usage accounting onto `GoalStore`. - Updated core session goal handling, app-server goal RPCs, resume snapshots, and goal tests to use the store boundary. - Kept thread deletion responsible for cascading goal cleanup by deleting the goal through the store only after a thread row is removed. ## Testing - Existing goal persistence, resume, and accounting tests were updated to exercise the new `GoalStore` access path. --- .../thread_goal_processor.rs | 7 + .../request_processors/thread_lifecycle.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 3 + codex-rs/core/src/goals.rs | 47 ++++- codex-rs/core/src/session/tests.rs | 10 ++ codex-rs/core/src/thread_manager_tests.rs | 2 + codex-rs/state/src/lib.rs | 1 + codex-rs/state/src/runtime.rs | 8 +- codex-rs/state/src/runtime/goals.rs | 160 ++++++++++++++---- codex-rs/state/src/runtime/threads.rs | 6 +- 10 files changed, 203 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 57d2575590..a7c9a2c20c 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -150,12 +150,14 @@ impl ThreadGoalRequestProcessor { let (goal, previous_status) = (if let Some(objective) = objective { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; if let Some(goal) = existing_goal.as_ref() { let previous_status = ExternalGoalPreviousStatus::from(goal); state_db + .thread_goals() .update_thread_goal( thread_id, codex_state::ThreadGoalUpdate { @@ -177,6 +179,7 @@ impl ThreadGoalRequestProcessor { } else { let previous_status = ExternalGoalPreviousStatus::NewGoal; state_db + .thread_goals() .replace_thread_goal( thread_id, objective, @@ -188,6 +191,7 @@ impl ThreadGoalRequestProcessor { } } else { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; @@ -198,6 +202,7 @@ impl ThreadGoalRequestProcessor { }; let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); state_db + .thread_goals() .update_thread_goal( thread_id, codex_state::ThreadGoalUpdate { @@ -246,6 +251,7 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; let goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? @@ -303,6 +309,7 @@ impl ThreadGoalRequestProcessor { thread_state.listener_command_tx() }; let cleared = state_db + .thread_goals() .delete_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; 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 4781df8350..c4d5fa6c96 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -676,7 +676,7 @@ pub(super) async fn send_thread_goal_snapshot_notification( thread_id: ThreadId, state_db: &StateDbHandle, ) { - match state_db.get_thread_goal(thread_id).await { + match state_db.thread_goals().get_thread_goal(thread_id).await { Ok(Some(goal)) => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index ff2ccec49c..852b86e562 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -943,10 +943,12 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; let thread_id = ThreadId::from_string(&thread_id)?; let persisted_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should exist"); state_db + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 12, @@ -974,6 +976,7 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> .await??; let edit: ThreadGoalSetResponse = to_response(edit_resp)?; let updated_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should still exist"); diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 4a7a9a3d0b..ce4c0a809d 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -422,6 +422,7 @@ impl Session { let state_db = self.require_state_db_for_thread_goals().await?; state_db + .thread_goals() .get_thread_goal(self.conversation_id) .await .map(|goal| goal.map(protocol_goal_from_state)) @@ -459,10 +460,14 @@ impl Session { let mut replacing_goal = false; let previous_status; let goal = if let Some(objective) = objective.as_deref() { - let existing_goal = state_db.get_thread_goal(self.conversation_id).await?; + let existing_goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); if let Some(existing_goal) = existing_goal.as_ref() { state_db + .thread_goals() .update_thread_goal( self.conversation_id, codex_state::ThreadGoalUpdate { @@ -482,6 +487,7 @@ impl Session { } else { replacing_goal = true; state_db + .thread_goals() .replace_thread_goal( self.conversation_id, objective, @@ -493,11 +499,15 @@ impl Session { .await? } } else { - let existing_goal = state_db.get_thread_goal(self.conversation_id).await?; + let existing_goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; previous_status = existing_goal.as_ref().map(|goal| goal.status); let expected_goal_id = existing_goal.map(|goal| goal.goal_id); let status = status.map(state_goal_status_from_protocol); state_db + .thread_goals() .update_thread_goal( self.conversation_id, codex_state::ThreadGoalUpdate { @@ -581,6 +591,7 @@ impl Session { ) .await?; let goal = state_db + .thread_goals() .insert_thread_goal( self.conversation_id, objective, @@ -760,7 +771,10 @@ impl Session { state_db: &StateDbHandle, expected_goal_id: Option<&str>, ) -> anyhow::Result> { - let goal = state_db.get_thread_goal(self.conversation_id).await?; + let goal = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await?; Ok(goal.and_then(|goal| { expected_goal_id .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) @@ -801,7 +815,11 @@ impl Session { return; } }; - match state_db.get_thread_goal(self.conversation_id).await { + match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) if matches!( goal.status, @@ -963,6 +981,7 @@ impl Session { .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) .await?; let outcome = state_db + .thread_goals() .account_thread_goal_usage( self.conversation_id, time_delta_seconds, @@ -1085,6 +1104,7 @@ impl Session { .await?; match state_db + .thread_goals() .account_thread_goal_usage( self.conversation_id, time_delta_seconds, @@ -1147,6 +1167,7 @@ impl Session { ) .await?; let Some(goal) = state_db + .thread_goals() .pause_active_thread_goal(self.conversation_id) .await? else { @@ -1192,7 +1213,11 @@ impl Session { let Some(state_db) = self.state_db_for_thread_goals().await? else { return Ok(()); }; - let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else { + let Some(goal) = state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await? + else { self.clear_stopped_thread_goal_runtime_state().await; return Ok(()); }; @@ -1237,7 +1262,11 @@ impl Session { Arc::clone(&active_turn.turn_state) }; let goal_is_current = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => match state_db.get_thread_goal(self.conversation_id).await { + Ok(Some(state_db)) => match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) if goal.goal_id == candidate.goal_id && goal.status == codex_state::ThreadGoalStatus::Active => @@ -1333,7 +1362,11 @@ impl Session { return None; } }; - let goal = match state_db.get_thread_goal(self.conversation_id).await { + let goal = match state_db + .thread_goals() + .get_thread_goal(self.conversation_id) + .await + { Ok(Some(goal)) => goal, Ok(None) => { tracing::debug!("skipping active goal continuation because no goal is set"); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 511dbfeabb..b0bc6330d4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8592,6 +8592,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted after accounting"); @@ -8615,6 +8616,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted after follow-up accounting"); @@ -8654,6 +8656,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8662,6 +8665,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a let previous_goal = goal.clone(); let goal_id = goal.goal_id.clone(); let updated_goal = state_db + .thread_goals() .update_thread_goal( sess.conversation_id, codex_state::ThreadGoalUpdate { @@ -8683,6 +8687,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a assert!(sess.active_turn.lock().await.is_some()); let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8709,6 +8714,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { let state_db = goal_test_state_db(sess.as_ref()).await?; let old_goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Keep improving the benchmark", @@ -8717,6 +8723,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { ) .await?; let new_goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Write a concise benchmark summary", @@ -8774,6 +8781,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: let state_db = goal_test_state_db(sess.as_ref()).await?; let goal = state_db + .thread_goals() .replace_thread_goal( sess.conversation_id, "Keep improving the benchmark", @@ -8807,6 +8815,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: .await?; let goal = state_db + .thread_goals() .get_thread_goal(sess.conversation_id) .await? .expect("goal should remain persisted"); @@ -8905,6 +8914,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a ) .await?; let persisted_goal = state_db + .thread_goals() .get_thread_goal(test.session_configured.thread_id) .await? .expect("goal should be persisted"); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 956486f182..43ec706f1a 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1561,6 +1561,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { .state_db() .expect("source thread should have a state db"); state_db + .thread_goals() .replace_thread_goal( source.thread_id, "Keep working until the task is done", @@ -1581,6 +1582,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { .await .expect("resume source thread"); let goal = state_db + .thread_goals() .get_thread_goal(resumed.thread_id) .await? .expect("goal should still exist after resume"); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 43c02faae2..aeb8b2d13b 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -48,6 +48,7 @@ pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; +pub use runtime::GoalStore; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::ThreadFilterOptions; pub use runtime::ThreadGoalAccountingMode; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index d6ed7bfcc0..e95cc7cc8a 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -18,7 +18,6 @@ use crate::apply_rollout_item; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_state_migrator; use crate::model::AgentJobRow; -use crate::model::ThreadGoalRow; use crate::model::ThreadRow; use crate::model::anchor_from_item; use crate::model::datetime_to_epoch_millis; @@ -65,6 +64,7 @@ mod remote_control; mod test_support; mod threads; +pub use goals::GoalStore; pub use goals::ThreadGoalAccountingMode; pub use goals::ThreadGoalAccountingOutcome; pub use goals::ThreadGoalUpdate; @@ -86,6 +86,7 @@ pub struct StateRuntime { default_provider: String, pool: Arc, logs_pool: Arc, + thread_goals: GoalStore, thread_updated_at_millis: Arc, } @@ -164,6 +165,7 @@ impl StateRuntime { let thread_updated_at_millis = thread_updated_at_millis_result?; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); let runtime = Arc::new(Self { + thread_goals: GoalStore::new(Arc::clone(&pool)), pool, logs_pool, codex_home, @@ -183,6 +185,10 @@ impl StateRuntime { pub fn codex_home(&self) -> &Path { self.codex_home.as_path() } + + pub fn thread_goals(&self) -> &GoalStore { + &self.thread_goals + } } fn base_sqlite_options(path: &Path) -> SqliteConnectOptions { diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 63dc63557c..b43e8f69d5 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -1,6 +1,18 @@ use super::*; +use crate::model::ThreadGoalRow; use uuid::Uuid; +#[derive(Clone)] +pub struct GoalStore { + pool: Arc, +} + +impl GoalStore { + pub(crate) fn new(pool: Arc) -> Self { + Self { pool } + } +} + pub struct ThreadGoalUpdate { pub objective: Option, pub status: Option, @@ -21,7 +33,7 @@ pub enum ThreadGoalAccountingMode { ActiveOrStopped, } -impl StateRuntime { +impl GoalStore { pub async fn get_thread_goal( &self, thread_id: ThreadId, @@ -308,29 +320,6 @@ WHERE thread_id = ? self.get_thread_goal(thread_id).await } - async fn set_thread_preview_if_empty( - &self, - thread_id: ThreadId, - preview: &str, - ) -> anyhow::Result<()> { - let preview = preview.trim(); - if preview.is_empty() { - return Ok(()); - } - sqlx::query( - r#" -UPDATE threads -SET preview = ? -WHERE id = ? AND preview = '' - "#, - ) - .bind(preview) - .bind(thread_id.to_string()) - .execute(self.pool.as_ref()) - .await?; - Ok(()) - } - pub async fn pause_active_thread_goal( &self, thread_id: ThreadId, @@ -463,6 +452,29 @@ RETURNING let updated = thread_goal_from_row(&row)?; Ok(ThreadGoalAccountingOutcome::Updated(updated)) } + + async fn set_thread_preview_if_empty( + &self, + thread_id: ThreadId, + preview: &str, + ) -> anyhow::Result<()> { + let preview = preview.trim(); + if preview.is_empty() { + return Ok(()); + } + sqlx::query( + r#" +UPDATE threads +SET preview = ? +WHERE id = ? AND preview = '' + "#, + ) + .bind(preview) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } } fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result { @@ -519,6 +531,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let goal = runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -529,7 +542,11 @@ mod tests { .expect("goal replacement should succeed"); assert_eq!( Some(goal.clone()), - runtime.get_thread_goal(thread_id).await.unwrap() + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() ); let metadata = runtime .get_thread(thread_id) @@ -539,6 +556,7 @@ mod tests { assert_eq!(metadata.preview.as_deref(), Some("hello")); let updated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -560,6 +578,7 @@ mod tests { assert_eq!(expected, updated); let replaced = runtime + .thread_goals() .replace_thread_goal( thread_id, "ship the new result", @@ -574,9 +593,28 @@ mod tests { assert_eq!(0, replaced.tokens_used); assert_eq!(0, replaced.time_used_seconds); - assert!(runtime.delete_thread_goal(thread_id).await.unwrap()); - assert_eq!(None, runtime.get_thread_goal(thread_id).await.unwrap()); - assert!(!runtime.delete_thread_goal(thread_id).await.unwrap()); + assert!( + runtime + .thread_goals() + .delete_thread_goal(thread_id) + .await + .unwrap() + ); + assert_eq!( + None, + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() + ); + assert!( + !runtime + .thread_goals() + .delete_thread_goal(thread_id) + .await + .unwrap() + ); } #[tokio::test] @@ -596,6 +634,7 @@ mod tests { .expect("test thread should be upserted"); runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -621,6 +660,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let replaced = runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -643,6 +683,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let inserted = runtime + .thread_goals() .insert_thread_goal( thread_id, "optimize the benchmark", @@ -654,6 +695,7 @@ mod tests { .expect("goal should be inserted"); let duplicate = runtime + .thread_goals() .insert_thread_goal( thread_id, "replace the benchmark", @@ -666,7 +708,11 @@ mod tests { assert_eq!(None, duplicate); assert_eq!( Some(inserted), - runtime.get_thread_goal(thread_id).await.unwrap() + runtime + .thread_goals() + .get_thread_goal(thread_id) + .await + .unwrap() ); } @@ -677,6 +723,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let inserted = runtime + .thread_goals() .insert_thread_goal( thread_id, "stay within budget", @@ -700,6 +747,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let original = runtime + .thread_goals() .replace_thread_goal( thread_id, "old objective", @@ -709,6 +757,7 @@ mod tests { .await .expect("goal replacement should succeed"); let replacement = runtime + .thread_goals() .replace_thread_goal( thread_id, "new objective", @@ -719,6 +768,7 @@ mod tests { .expect("goal replacement should succeed"); let stale_update = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -735,12 +785,14 @@ mod tests { assert_eq!( Some(replacement.clone()), runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") ); let fresh_update = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -763,6 +815,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; let original = runtime + .thread_goals() .replace_thread_goal( thread_id, "old objective", @@ -772,6 +825,7 @@ mod tests { .await .expect("goal replacement should succeed"); let replacement = runtime + .thread_goals() .replace_thread_goal( thread_id, "new objective", @@ -782,6 +836,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -809,6 +864,7 @@ mod tests { upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "draft the report", @@ -818,6 +874,7 @@ mod tests { .await .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 12, @@ -832,6 +889,7 @@ mod tests { }; let updated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -860,6 +918,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -869,7 +928,7 @@ mod tests { .await .expect("goal replacement should succeed"); - let status_update = runtime.update_thread_goal( + let status_update = runtime.thread_goals().update_thread_goal( thread_id, ThreadGoalUpdate { objective: None, @@ -878,7 +937,7 @@ mod tests { expected_goal_id: None, }, ); - let budget_update = runtime.update_thread_goal( + let budget_update = runtime.thread_goals().update_thread_goal( thread_id, ThreadGoalUpdate { objective: None, @@ -892,6 +951,7 @@ mod tests { budget_update.expect("budget update should succeed"); let goal = runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -906,6 +966,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; let goal = runtime + .thread_goals() .replace_thread_goal( thread_id, "optimize the benchmark", @@ -916,6 +977,7 @@ mod tests { .expect("goal replacement should succeed"); let paused = runtime + .thread_goals() .pause_active_thread_goal(thread_id) .await .expect("active pause should succeed") @@ -928,6 +990,7 @@ mod tests { assert_eq!(expected, paused); let complete = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -941,6 +1004,7 @@ mod tests { .expect("goal update should succeed") .expect("goal should exist"); let pause_result = runtime + .thread_goals() .pause_active_thread_goal(thread_id) .await .expect("terminal pause attempt should succeed"); @@ -948,6 +1012,7 @@ mod tests { assert_eq!( Some(complete), runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -960,6 +1025,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -970,6 +1036,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 7, @@ -987,6 +1054,7 @@ mod tests { assert_eq!(7, goal.time_used_seconds); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 3, @@ -1004,6 +1072,7 @@ mod tests { assert_eq!(10, goal.time_used_seconds); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -1027,6 +1096,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay stopped", @@ -1037,6 +1107,7 @@ mod tests { .expect("goal replacement should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 5, @@ -1060,6 +1131,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stop before overrun", @@ -1069,6 +1141,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .update_thread_goal( thread_id, crate::ThreadGoalUpdate { @@ -1082,6 +1155,7 @@ mod tests { .expect("goal update should succeed"); let outcome = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 3, @@ -1105,6 +1179,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1114,6 +1189,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1125,6 +1201,7 @@ mod tests { .expect("usage accounting should succeed"); let lowered = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1149,6 +1226,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1158,6 +1236,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1169,6 +1248,7 @@ mod tests { .expect("usage accounting should succeed"); let reactivated = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1197,6 +1277,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "stay within budget", @@ -1206,6 +1287,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 1, @@ -1217,6 +1299,7 @@ mod tests { .expect("usage accounting should succeed"); let paused = runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1241,6 +1324,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "finish the report", @@ -1251,6 +1335,7 @@ mod tests { .expect("goal replacement should succeed"); let active_only = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1268,6 +1353,7 @@ mod tests { assert_eq!(0, goal.time_used_seconds); let completing_turn = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1291,6 +1377,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "finish the report", @@ -1300,6 +1387,7 @@ mod tests { .await .expect("goal replacement should succeed"); runtime + .thread_goals() .update_thread_goal( thread_id, ThreadGoalUpdate { @@ -1314,6 +1402,7 @@ mod tests { .expect("goal should exist"); let active_only = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1331,6 +1420,7 @@ mod tests { assert_eq!(0, goal.time_used_seconds); let in_flight_turn = runtime + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 30, @@ -1354,6 +1444,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "count every token", @@ -1363,14 +1454,14 @@ mod tests { .await .expect("goal replacement should succeed"); - let first = runtime.account_thread_goal_usage( + let first = runtime.thread_goals().account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 4, /*token_delta*/ 40, ThreadGoalAccountingMode::ActiveOnly, /*expected_goal_id*/ None, ); - let second = runtime.account_thread_goal_usage( + let second = runtime.thread_goals().account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 6, /*token_delta*/ 60, @@ -1382,6 +1473,7 @@ mod tests { second.expect("second usage accounting should succeed"); let goal = runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") @@ -1396,6 +1488,7 @@ mod tests { let thread_id = test_thread_id(); upsert_test_thread(&runtime, thread_id).await; runtime + .thread_goals() .replace_thread_goal( thread_id, "clean up with the thread", @@ -1413,6 +1506,7 @@ mod tests { assert_eq!( None, runtime + .thread_goals() .get_thread_goal(thread_id) .await .expect("goal read should succeed") diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 925fb45288..c8c48db721 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -947,7 +947,11 @@ ON CONFLICT(thread_id, position) DO NOTHING .bind(thread_id.to_string()) .execute(self.pool.as_ref()) .await?; - Ok(result.rows_affected()) + let rows_affected = result.rows_affected(); + if rows_affected > 0 { + self.thread_goals.delete_thread_goal(thread_id).await?; + } + Ok(rows_affected) } } From 500ef67ed15fae5148d5cfcdf42973ffead19b12 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 15:19:23 +0200 Subject: [PATCH 052/114] chore: goal resumed metrics (#23301) Add metrics for goal resume --- codex-rs/core/src/goals.rs | 8 ++++++++ codex-rs/otel/src/metrics/names.rs | 1 + 2 files changed, 9 insertions(+) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index ce4c0a809d..717afb65c2 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -19,6 +19,7 @@ use codex_otel::GOAL_BUDGET_LIMITED_METRIC; use codex_otel::GOAL_COMPLETED_METRIC; use codex_otel::GOAL_CREATED_METRIC; use codex_otel::GOAL_DURATION_SECONDS_METRIC; +use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; @@ -734,6 +735,12 @@ impl Session { .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); } + fn emit_goal_resumed_metric(&self) { + self.services + .session_telemetry + .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); + } + fn emit_goal_terminal_metrics_if_status_changed( &self, previous_status: Option, @@ -1229,6 +1236,7 @@ impl Session { .await .wall_clock .mark_active_goal(goal.goal_id); + self.emit_goal_resumed_metric(); } codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::BudgetLimited diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 82fae06734..2d7dfdf880 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -29,6 +29,7 @@ pub const TURN_MEMORY_METRIC: &str = "codex.turn.memory"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; +pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; From b631d9217025b9960510808443125da3fffc7857 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 15:59:06 +0200 Subject: [PATCH 053/114] chore: make token usage async (#23305) Make the `TokenUsageContributor` async. This will be required for future extension and it's basically free --- codex-rs/core/src/session/mod.rs | 14 ++++++++------ codex-rs/core/src/session/tests.rs | 3 ++- codex-rs/ext/extension-api/src/contributors.rs | 3 ++- codex-rs/ext/goal/src/extension.rs | 5 ++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d67e79f794..315dc93c6f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2946,12 +2946,14 @@ impl Session { }; if let Some(token_info) = token_info.as_ref() { for contributor in self.services.extensions.token_usage_contributors() { - contributor.on_token_usage( - &self.services.session_extension_data, - &self.services.thread_extension_data, - turn_context.extension_data.as_ref(), - token_info, - ); + contributor + .on_token_usage( + &self.services.session_extension_data, + &self.services.thread_extension_data, + turn_context.extension_data.as_ref(), + token_info, + ) + .await; } } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b0bc6330d4..4511df4583 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1874,8 +1874,9 @@ async fn record_token_usage_info_notifies_extension_contributors() { records: Arc>>, } + #[async_trait::async_trait] impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder { - fn on_token_usage( + async fn on_token_usage( &self, session_store: &codex_extension_api::ExtensionData, thread_store: &codex_extension_api::ExtensionData, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index b03846fd31..4aa28d1049 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -88,9 +88,10 @@ pub trait ConfigContributor: Send + Sync { /// Implementations should keep this callback cheap. The host calls it after /// updating cached token usage and before emitting the corresponding client /// token-count notification. +#[async_trait::async_trait] pub trait TokenUsageContributor: Send + Sync { /// Called each time the host records token usage from a model response. - fn on_token_usage( + async fn on_token_usage( &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 97d7157298..3204b9eb92 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -179,11 +179,12 @@ where } } +#[async_trait] impl TokenUsageContributor for GoalExtension where C: Send + Sync + 'static, { - fn on_token_usage( + async fn on_token_usage( &self, _session_store: &ExtensionData, thread_store: &ExtensionData, @@ -204,8 +205,6 @@ where // this recorded delta can be committed to the active persisted goal. // It also needs an event/input capability to emit ThreadGoalUpdated and // inject budget-limit steering when accounting changes goal status. - // TODO: if the storage/event path must await, TokenUsageContributor - // either needs to become async or receive a fire-and-forget host sink. } } From 4ca60ef9fffe76fb4f86d606f7d4a2f727f6cd25 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 16:14:37 +0200 Subject: [PATCH 054/114] Emit goal update events from goal extension tools (#23306) ## Why Goal creation and completion are moving through the goal extension, but the rest of Codex still observes goal state through `ThreadGoalUpdated` events. Without an event from the extension-owned tool path, a model-initiated `create_goal` or `update_goal` can mutate the backend and return a tool result while app-server and TUI listeners miss the goal state transition. ## What changed - Added `GoalEventEmitter` as a small wrapper around the host `ExtensionEventSink` to build `EventMsg::ThreadGoalUpdated` events for goal updates. - Threaded the registry event sink into `GoalExtension` and the `GoalToolExecutor`s created by the extension. The public `GoalExtension::new` constructor keeps a `NoopExtensionEventSink` fallback for standalone use. - Emitted a goal update after successful `create_goal` and `update_goal` tool calls. Until `ToolCall` exposes the current turn submission id, these events use the tool call id as the event id and leave `turn_id` unset. Relevant code: - [`GoalEventEmitter::thread_goal_updated`](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/events.rs#L19-L32) - [`GoalToolExecutor` emission points](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/tool.rs#L161-L190) ## Testing - `cargo test -p codex-goal-extension` --- codex-rs/ext/goal/src/events.rs | 34 ++++++++++++++++++++++++++++ codex-rs/ext/goal/src/extension.rs | 27 ++++++++++++++++++++-- codex-rs/ext/goal/src/lib.rs | 1 + codex-rs/ext/goal/src/tool.rs | 36 +++++++++++++++++++++++++++--- 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 codex-rs/ext/goal/src/events.rs diff --git a/codex-rs/ext/goal/src/events.rs b/codex-rs/ext/goal/src/events.rs new file mode 100644 index 0000000000..ab9eda4056 --- /dev/null +++ b/codex-rs/ext/goal/src/events.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use codex_extension_api::ExtensionEventSink; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; + +#[derive(Clone)] +pub(crate) struct GoalEventEmitter { + sink: Arc, +} + +impl GoalEventEmitter { + pub(crate) fn new(sink: Arc) -> Self { + Self { sink } + } + + pub(crate) fn thread_goal_updated( + &self, + event_id: impl Into, + turn_id: Option, + goal: ThreadGoal, + ) { + self.sink.emit(Event { + id: event_id.into(), + msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: goal.thread_id, + turn_id, + goal, + }), + }); + } +} diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 3204b9eb92..a0ce7117f4 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use async_trait::async_trait; use codex_extension_api::ConfigContributor; use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopExtensionEventSink; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TokenUsageContributor; @@ -18,6 +20,7 @@ use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; use crate::accounting::GoalAccountingState; +use crate::events::GoalEventEmitter; use crate::tool::CreateGoalRequest; use crate::tool::GoalToolExecutor; @@ -35,6 +38,7 @@ impl GoalExtensionConfig { #[derive(Clone)] pub struct GoalExtension { backend: Arc, + event_emitter: GoalEventEmitter, goals_enabled: Arc bool + Send + Sync>, } @@ -48,9 +52,18 @@ impl GoalExtension { pub fn new( backend: Arc, goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, + ) -> Self { + Self::new_with_event_sink(backend, Arc::new(NoopExtensionEventSink), goals_enabled) + } + + pub fn new_with_event_sink( + backend: Arc, + event_sink: Arc, + goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static, ) -> Self { Self { backend, + event_emitter: GoalEventEmitter::new(event_sink), goals_enabled: Arc::new(goals_enabled), } } @@ -231,14 +244,20 @@ where return Vec::new(); }; vec![ - Arc::new(GoalToolExecutor::get(thread_id, Arc::clone(&self.backend))), + Arc::new(GoalToolExecutor::get( + thread_id, + Arc::clone(&self.backend), + self.event_emitter.clone(), + )), Arc::new(GoalToolExecutor::create( thread_id, Arc::clone(&self.backend), + self.event_emitter.clone(), )), Arc::new(GoalToolExecutor::update( thread_id, Arc::clone(&self.backend), + self.event_emitter.clone(), )), ] } @@ -260,7 +279,11 @@ pub fn install_with_backend( ) where C: Send + Sync + 'static, { - let extension = Arc::new(GoalExtension::new(backend, goals_enabled)); + let extension = Arc::new(GoalExtension::new_with_event_sink( + backend, + registry.event_sink(), + goals_enabled, + )); registry.thread_lifecycle_contributor(extension.clone()); registry.config_contributor(extension.clone()); registry.turn_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 17a43e4671..38332aee41 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -5,6 +5,7 @@ //! accounting that can be represented with today's extension API. mod accounting; +mod events; mod extension; mod spec; mod tool; diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index d19dc81056..8ad4541a3c 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::validate_thread_goal_objective; use serde::Deserialize; use serde::Serialize; +use crate::events::GoalEventEmitter; use crate::extension::GoalToolBackend; use crate::spec::CREATE_GOAL_TOOL_NAME; use crate::spec::GET_GOAL_TOOL_NAME; @@ -28,6 +29,7 @@ pub(crate) struct GoalToolExecutor { kind: GoalToolKind, thread_id: ThreadId, backend: Arc, + event_emitter: GoalEventEmitter, } #[derive(Clone, Copy)] @@ -65,27 +67,42 @@ enum CompletionBudgetReport { } impl GoalToolExecutor { - pub(crate) fn get(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn get( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Get, thread_id, backend, + event_emitter, } } - pub(crate) fn create(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn create( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Create, thread_id, backend, + event_emitter, } } - pub(crate) fn update(thread_id: ThreadId, backend: Arc) -> Self { + pub(crate) fn update( + thread_id: ThreadId, + backend: Arc, + event_emitter: GoalEventEmitter, + ) -> Self { Self { kind: GoalToolKind::Update, thread_id, backend, + event_emitter, } } } @@ -146,6 +163,7 @@ impl GoalToolExecutor { .create_goal(self.thread_id, request) .await .map_err(FunctionCallError::RespondToModel)?; + self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Omit) } @@ -168,8 +186,20 @@ impl GoalToolExecutor { .complete_goal(self.thread_id) .await .map_err(FunctionCallError::RespondToModel)?; + self.emit_goal_updated_from_tool_call(&invocation, goal.clone()); goal_response(Some(goal), CompletionBudgetReport::Include) } + + fn emit_goal_updated_from_tool_call(&self, invocation: &ToolCall, goal: ThreadGoal) { + // TODO: ToolCall should expose the current turn submission id so goal + // tool events can set ThreadGoalUpdatedEvent.turn_id exactly as core + // does today. Until then, correlate the event with the tool call id. + self.event_emitter.thread_goal_updated( + invocation.call_id.clone(), + /*turn_id*/ None, + goal, + ); + } } fn parse_arguments(arguments: &str) -> Result From fce10e009d2b56536d01145a809b8e862dbb671d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 08:52:18 -0700 Subject: [PATCH 055/114] tui: keep cleared Fast tier from reappearing after side-thread resume (#23121) ## Why After turning Fast mode off in the TUI, returning from a side thread could make `Fast` appear again in the main chat widget. The opt-out itself was still persisted; the display was being rebuilt from stale cached `ThreadSessionState` data, which made it look like Fast had been re-enabled. Fixes #23104. ## What changed - Keep the active thread's cached `service_tier` in sync whenever the user persists a service-tier selection. - Update both the primary-thread snapshot and the thread event store so restored TUI state reflects the current tier. - Add a focused regression test for clearing a cached Fast tier. ## Manual repro 1. Start a TUI session where `Fast` is enabled by default. 2. Run `/fast` and turn Fast mode off. Confirm `Fast` disappears from the chat widget display. 3. Re-enter thread navigation via either path: - Run `/side test`, then return to the main thread. - Run `/agent`, enter a child thread, then return to the main thread. 4. Before this fix, `Fast` reappears in the main chat widget display even though the opt-out was already persisted. 5. After this fix, `Fast` stays cleared. ## Verification - `cargo test -p codex-tui app::thread_session_state::tests::service_tier_sync_updates_active_cached_session -- --exact` --- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/thread_session_state.rs | 69 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 9271089c43..efb9a26c63 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1303,8 +1303,10 @@ impl App { } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); - let profile = self.active_profile.as_deref(); self.config.service_tier = service_tier.clone(); + self.sync_active_thread_service_tier_to_cached_session() + .await; + let profile = self.active_profile.as_deref(); let edits = crate::config_update::build_service_tier_selection_edits( profile, service_tier.as_deref(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4037a085eb..6f2b3ec7f1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -8,6 +8,30 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; impl App { + pub(super) async fn sync_active_thread_service_tier_to_cached_session(&mut self) { + let Some(active_thread_id) = self.active_thread_id else { + return; + }; + + let service_tier = self.chat_widget.current_service_tier().map(str::to_string); + let update_session = |session: &mut ThreadSessionState| { + session.service_tier = service_tier.clone(); + }; + + if self.primary_thread_id == Some(active_thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + update_session(session); + } + + if let Some(channel) = self.thread_event_channels.get(&active_thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + update_session(session); + } + } + } + pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) { let Some(active_thread_id) = self.active_thread_id else { return; @@ -125,6 +149,7 @@ mod tests { use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; + use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -316,6 +341,50 @@ mod tests { assert_eq!(store_session, Some(expected_session)); } + #[tokio::test] + async fn service_tier_sync_updates_active_cached_session() { + let mut app = make_test_app().await; + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000406").expect("valid thread"); + let session = ThreadSessionState { + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()), + ); + app.chat_widget.handle_thread_session(session); + app.chat_widget.set_service_tier(/*service_tier*/ None); + + app.sync_active_thread_service_tier_to_cached_session() + .await; + + let expected_session = ThreadSessionState { + service_tier: None, + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + assert_eq!( + app.primary_session_configured, + Some(expected_session.clone()) + ); + + let store_session = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel") + .store + .lock() + .await + .session + .clone(); + assert_eq!(store_session, Some(expected_session)); + } + #[tokio::test] async fn thread_read_fallback_uses_active_permission_settings() { let mut app = make_test_app().await; From af6ffb6ebb4c1aad5793bb1eff4ea1ac785cd22d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 08:55:22 -0700 Subject: [PATCH 056/114] Support --output-schema for exec resume (#23123) ## Why `codex exec resume` should have the same structured-output support as top-level `codex exec`. Without `--output-schema`, multi-turn automation has to choose between resumed session context and schema-validated JSON output. Fixes #22998. ## What changed - Marked `--output-schema` as a global `codex exec` flag so it can be passed after `resume`. - Reused the existing output schema plumbing so resumed turns attach the schema to the final response request while preserving session context. --- codex-rs/cli/src/main.rs | 8 ++++- codex-rs/exec/src/cli.rs | 2 +- codex-rs/exec/src/cli_tests.rs | 5 ++- codex-rs/exec/tests/suite/resume.rs | 56 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6c79c34c99..8274510b9f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2346,7 +2346,7 @@ mod tests { } #[test] - fn exec_resume_accepts_output_last_message_flag_after_subcommand() { + fn exec_resume_accepts_output_flags_after_subcommand() { let cli = MultitoolCli::try_parse_from([ "codex", "exec", @@ -2354,6 +2354,8 @@ mod tests { "session-123", "-o", "/tmp/resume-output.md", + "--output-schema", + "/tmp/schema.json", "re-review", ]) .expect("parse should succeed"); @@ -2369,6 +2371,10 @@ mod tests { exec.last_message_file, Some(std::path::PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!( + exec.output_schema, + Some(std::path::PathBuf::from("/tmp/schema.json")) + ); assert_eq!(args.session_id.as_deref(), Some("session-123")); assert_eq!(args.prompt.as_deref(), Some("re-review")); } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 3a5ebfd1ba..53124c11a5 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -50,7 +50,7 @@ pub struct Cli { pub removed_full_auto: bool, /// Path to a JSON Schema file describing the model's final response shape. - #[arg(long = "output-schema", value_name = "FILE")] + #[arg(long = "output-schema", value_name = "FILE", global = true)] pub output_schema: Option, #[clap(skip)] diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 45f2aa330d..524067dd66 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -36,7 +36,7 @@ fn resume_parses_prompt_after_global_flags() { } #[test] -fn resume_accepts_output_last_message_flag_after_subcommand() { +fn resume_accepts_output_flags_after_subcommand() { const PROMPT: &str = "echo resume-with-output-file"; let cli = Cli::parse_from([ "codex-exec", @@ -44,6 +44,8 @@ fn resume_accepts_output_last_message_flag_after_subcommand() { "session-123", "-o", "/tmp/resume-output.md", + "--output-schema", + "/tmp/schema.json", PROMPT, ]); @@ -51,6 +53,7 @@ fn resume_accepts_output_last_message_flag_after_subcommand() { cli.last_message_file, Some(PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!(cli.output_schema, Some(PathBuf::from("/tmp/schema.json"))); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 2bf6416ccc..904c6a8f19 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -375,6 +375,62 @@ async fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_resume_includes_output_schema_in_request() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let test = test_codex_exec(); + let server = MockServer::start().await; + let response_mock = mount_exec_responses(&server, /*count*/ 2).await; + + let schema_contents = serde_json::json!({ + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"], + "additionalProperties": false + }); + let schema_path = test.cwd_path().join("schema.json"); + std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("echo seed-resume-session") + .assert() + .success(); + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("resume") + .arg("--last") + .arg("--json") + .arg("--output-schema") + .arg(&schema_path) + .arg("echo resume-with-schema") + .assert() + .success(); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let payload: Value = requests[1].body_json(); + let text = payload.get("text").expect("request missing text field"); + let format = text + .get("format") + .expect("request missing text.format field"); + assert_eq!( + format, + &serde_json::json!({ + "name": "codex_output_schema", + "type": "json_schema", + "strict": true, + "schema": schema_contents, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From deb159d9ffb848b60a9c63ab9d2ce014d7093b0f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:00:57 -0700 Subject: [PATCH 057/114] Fix TUI stream cleanup after turn errors (#23128) ## Summary Fixes #22726. After a Responses stream disconnect, the live TUI could keep accepting prompts while leaving partially streamed assistant output in its transient streaming-cell form. That made fenced diffs or SVG/XML-like content appear as raw transcript text until the user closed the TUI and resumed the same session, which rebuilt the transcript from saved history. This change finalizes the active answer stream before generic failed-turn cleanup clears the stream controller, so the live transcript takes the same source-backed markdown consolidation path as a successful turn. ## Reviewer repro 1. Start a local Codex TUI session. 2. Trigger an assistant turn that streams markdown content, especially a fenced diff or SVG/XML-like block. 3. Force or encounter a non-retry stream disconnect before the turn completes. 4. Continue using the same still-open TUI session. 5. Before this fix, the live history can stay raw/plain even though `codex resume` renders the same session normally. 6. After this fix, the failed-turn path consolidates the partial stream before rendering the error, so the live TUI keeps normal transcript rendering. --- .../tui/src/chatwidget/tests/app_server.rs | 34 +++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 1 + 2 files changed, 35 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 26e38900ce..688e30bd17 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -636,6 +636,40 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn live_app_server_failed_turn_consolidates_streamed_answer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_turn_started(&mut chat, "turn-1"); + while rx.try_recv().is_ok() {} + + handle_agent_message_delta(&mut chat, "```diff\n+ streamed patch\n```\n"); + chat.run_commit_tick(); + while rx.try_recv().is_ok() {} + + handle_error( + &mut chat, + "stream disconnected before completion", + /*codex_error_info*/ None, + ); + + let mut saw_consolidate = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::ConsolidateAgentMessage { source, .. } = event { + saw_consolidate = true; + assert!( + source.contains("streamed patch"), + "expected partial stream source to be consolidated, got {source:?}" + ); + } + } + + assert!( + saw_consolidate, + "failed turn should consolidate streamed cells before clearing the stream controller" + ); +} + #[tokio::test] async fn live_app_server_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index d82eca338f..4868d6a25c 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -339,6 +339,7 @@ impl ChatWidget { pub(super) fn on_error(&mut self, message: String) { self.input_queue.submit_pending_steers_after_interrupt = false; + self.flush_answer_stream_with_separator(); self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.set_ambient_pet_notification( From e734cb5713086aa31db81f9180c78c0742a446b9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:02:38 -0700 Subject: [PATCH 058/114] Hide ChatGPT usage link for non-OpenAI status (#23127) Addresses #22778 ## Summary Provider deployments such as Bedrock manage rate limits and billing outside ChatGPT, so the `/status` link to the ChatGPT usage page is irrelevant and confusing for those users. Custom providers that are explicitly configured to use OpenAI/ChatGPT auth still point at OpenAI-backed usage, so they should keep the link. ## Changes - Render the ChatGPT usage note only when the configured provider uses OpenAI auth. - Keep the note hidden when `/status` displays a provider such as Bedrock that manages limits elsewhere. - Add regression coverage for both Bedrock and a custom OpenAI-auth proxy provider. ## Manual Repro 1. Configure Codex with a non-OpenAI-auth provider, for example `model_provider = "amazon-bedrock"`. 2. Start the TUI and run `/status`. 3. Confirm the status card shows the custom provider, for example `Model provider: Amazon Bedrock`, and does not show `https://chatgpt.com/codex/settings/usage`. 4. Configure a custom provider that proxies to OpenAI and has OpenAI/ChatGPT auth enabled. 5. Run `/status` again and confirm the ChatGPT usage link appears for that OpenAI-auth provider. --- codex-rs/tui/src/status/card.rs | 11 +++++++-- codex-rs/tui/src/status/tests.rs | 38 +++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 3d30f800b6..7f7768e2cf 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -106,6 +106,7 @@ struct StatusHistoryCell { agents_summary: Arc>, collaboration_mode: Option, model_provider: Option, + show_chatgpt_usage_link: bool, account: Option, thread_name: Option, session_id: Option, @@ -308,6 +309,7 @@ impl StatusHistoryCell { workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); + let show_chatgpt_usage_link = config.model_provider.requires_openai_auth; let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); let forked_from = forked_from.map(|id| id.to_string()); @@ -347,6 +349,7 @@ impl StatusHistoryCell { permissions, collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, + show_chatgpt_usage_link, account, thread_name, session_id, @@ -765,8 +768,12 @@ impl HistoryCell for StatusHistoryCell { [note_first_line, note_second_line], RtOptions::new(available_inner_width), ); - lines.extend(note_lines); - lines.push(Line::from(Vec::>::new())); + // The ChatGPT usage page only applies to providers backed by OpenAI auth; + // providers like Bedrock manage limits and billing elsewhere. + if self.show_chatgpt_usage_link { + lines.extend(note_lines); + lines.push(Line::from(Vec::>::new())); + } let mut model_spans = vec![Span::from(self.model_name.clone())]; if !self.model_details.is_empty() { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 3a10e0ed94..286767e647 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -582,7 +582,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { } #[tokio::test] -async fn status_model_provider_uses_bedrock_runtime_base_url() { +async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_link() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model_provider_id = "amazon-bedrock".to_string(); @@ -629,6 +629,42 @@ async fn status_model_provider_uses_bedrock_runtime_base_url() { !rendered.contains("bedrock-mantle.us-east-1"), "expected /status to ignore configured Bedrock base URL, got: {rendered}" ); + assert!( + !rendered.contains("https://chatgpt.com/codex/settings/usage"), + "expected /status to hide ChatGPT usage link for Bedrock, got: {rendered}" + ); + + config.model_provider_id = "openai-proxy".to_string(); + config.model_provider = ModelProviderInfo { + name: "OpenAI Proxy".to_string(), + base_url: Some("https://openai-proxy.example/v1".to_string()), + requires_openai_auth: true, + ..ModelProviderInfo::default() + }; + let (composite, _handle) = new_status_output_with_rate_limits_handle( + &config, + /*runtime_model_provider_base_url*/ None, + test_status_account_display().as_ref(), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ &[], + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + "".to_string(), + /*refreshing_rate_limits*/ false, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); + + assert!( + rendered.contains("https://chatgpt.com/codex/settings/usage"), + "expected /status to show ChatGPT usage link for OpenAI-auth proxy, got: {rendered}" + ); } #[tokio::test] From adca1b643fd0d2733030ef4fdaf5273036f02d9a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 09:04:02 -0700 Subject: [PATCH 059/114] [1 of 2] Optimize TUI startup terminal probes (#23175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Codex TUI startup still feels slower than 0.117.0 after the app-server move in 0.118.0. A visible chunk of launch-to-input latency comes from serial terminal startup probes: cursor position, keyboard enhancement support, and default foreground/background color queries can each wait on terminal responses before the first usable frame. Refs #16335. ## What This PR batches the terminal startup probes into one bounded probe. It also reuses the probed cursor position and default colors during TUI setup, fast-paths the primary-device-attributes fallback as keyboard enhancement unsupported, and keeps lightweight startup timing logs for future tuning. The startup telemetry is intentionally left in production: it records phase timings for terminal probes and initial-frame scheduling so future startup regressions can be diagnosed from normal logs rather than re-adding one-off debug instrumentation. ## Benchmark In the local pty startup benchmark, the pre-optimization `main` baseline was about 250.5ms median from launch to accepted chat input. This probe-only branch measured about 152ms median, for an approximate savings of 95-100ms. ## Stack 1. [#23175: [1 of 2] Optimize TUI startup terminal probes](https://github.com/openai/codex/pull/23175) — this PR 2. [#23176: [2 of 2] Start fresh TUI thread in background](https://github.com/openai/codex/pull/23176) — layered on this PR ## Verification - `cargo test -p codex-tui` --- codex-rs/tui/src/app.rs | 19 +++ codex-rs/tui/src/lib.rs | 9 +- codex-rs/tui/src/terminal_palette.rs | 25 ++++ codex-rs/tui/src/terminal_probe.rs | 190 ++++++++++++++++++++------- codex-rs/tui/src/tui.rs | 84 ++++++++---- 5 files changed, 254 insertions(+), 73 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 46a7be00dd..07ac30f046 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -660,6 +660,7 @@ impl App { startup_hooks_browser: Option, ) -> Result { use tokio_stream::StreamExt; + let startup_started_at = Instant::now(); let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); @@ -703,7 +704,9 @@ impl App { }); } }; + let bootstrap_started_at = Instant::now(); let bootstrap = app_server.bootstrap(&config).await?; + let bootstrap_ms = bootstrap_started_at.elapsed().as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let exit_info = handle_model_migration_prompt_if_needed( @@ -760,8 +763,10 @@ impl App { let workspace_command_runner: WorkspaceCommandRunner = Arc::new( AppServerWorkspaceCommandRunner::new(app_server.request_handle()), ); + let runtime_model_provider_started_at = Instant::now(); let runtime_model_provider_base_url = resolve_runtime_model_provider_base_url(&config.model_provider).await; + let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis(); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -772,6 +777,7 @@ impl App { &initial_prompt, &initial_images, ); + let thread_and_widget_started_at = Instant::now(); let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { let started = app_server.start_thread(&config).await?; @@ -888,6 +894,7 @@ impl App { (ChatWidget::new_with_app_event(init), Some(forked)) } }; + let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); if let Some(message) = external_agent_config_migration_message { chat_widget.add_info_message(message, /*hint*/ None); } @@ -958,6 +965,7 @@ See the Codex keymap documentation for supported actions and examples." if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); } + let initial_session_started_at = Instant::now(); if let Some(started) = initial_started_thread { let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) @@ -967,6 +975,7 @@ See the Codex keymap documentation for supported actions and examples." .await; } } + let initial_session_ms = initial_session_started_at.elapsed().as_millis(); // On startup, if a managed filesystem sandbox is active, warn about // world-writable dirs on Windows. @@ -996,10 +1005,20 @@ See the Codex keymap documentation for supported actions and examples." } } + let event_stream_started_at = Instant::now(); let tui_events = tui.event_stream(); tokio::pin!(tui_events); tui.frame_requester().schedule_frame(); + tracing::info!( + duration_ms = %startup_started_at.elapsed().as_millis(), + bootstrap_ms = %bootstrap_ms, + runtime_model_provider_ms = %runtime_model_provider_ms, + thread_and_widget_ms = %thread_and_widget_ms, + initial_session_ms = %initial_session_ms, + event_stream_ms = %event_stream_started_at.elapsed().as_millis(), + "tui startup initial frame scheduled" + ); app.refresh_startup_skills(&app_server); // Kick off a non-blocking rate-limit prefetch so the first `/status` // already has data, without delaying the initial frame render. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 871cdcc0dd..9ece5e3776 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1219,10 +1219,13 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); - let mut terminal = tui::init()?; - terminal.clear()?; + let mut initialized_terminal = tui::init()?; + initialized_terminal.terminal.clear()?; - let mut tui = Tui::new(terminal); + let mut tui = Tui::new( + initialized_terminal.terminal, + initialized_terminal.enhanced_keys_supported, + ); let mut terminal_restore_guard = TerminalRestoreGuard::new(); #[cfg(not(debug_assertions))] diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 229a97d947..53c68d96e7 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -68,6 +68,13 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } +#[cfg(unix)] +pub(crate) fn set_default_colors_from_startup_probe( + colors: Option, +) { + imp::set_default_colors_from_startup_probe(colors); +} + #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; @@ -112,6 +119,18 @@ mod imp { cache.get_or_init_with(query_default_colors) } + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + pub(super) fn requery_default_colors() { if let Ok(mut cache) = default_colors_cache().lock() { // Don't try to refresh if the cache is already attempted and failed. @@ -166,6 +185,12 @@ mod imp { None } + #[cfg(unix)] + pub(super) fn set_default_colors_from_startup_probe( + _colors: Option, + ) { + } + pub(super) fn requery_default_colors() {} } diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index c4e0f57049..d9927ffa20 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -39,6 +39,21 @@ mod imp { pub(crate) bg: (u8, u8, u8), } + /// Results from the TUI's one-shot startup terminal probe. + #[derive(Debug, Clone, Copy, Eq, PartialEq)] + pub(crate) struct StartupProbe { + pub(crate) cursor_position: Option, + pub(crate) default_colors: Option, + pub(crate) keyboard_enhancement_supported: Option, + } + + /// Whether the startup probe should query keyboard enhancement support. + #[derive(Clone, Copy, Eq, PartialEq)] + pub(crate) enum StartupKeyboardEnhancementProbe { + Query, + Skip, + } + /// Temporary terminal handle used while a startup probe owns terminal input. /// /// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the @@ -198,19 +213,6 @@ mod imp { Ok(unsafe { File::from_raw_fd(duplicated) }) } - /// Queries the current cursor position and returns a zero-based Ratatui position. - /// - /// A timeout or a non-CPR response is not fatal. Callers should treat `Ok(None)` as "terminal - /// did not answer this optional query" and choose a conservative fallback. - pub(crate) fn cursor_position(timeout: Duration) -> io::Result> { - let mut tty = Tty::open()?; - tty.write_all(b"\x1B[6n")?; - let Some(response) = read_until(&mut tty, timeout, parse_cursor_position)? else { - return Ok(None); - }; - Ok(Some(response)) - } - /// Queries OSC 10 and OSC 11 default colors under one shared deadline. /// /// Foreground and background are only useful as a pair for palette calculations, so a missing @@ -226,16 +228,24 @@ mod imp { Ok(Some(colors)) } - /// Checks whether the terminal reports support for keyboard enhancement flags. + /// Runs the optional terminal queries needed during TUI startup under one shared deadline. /// - /// The probe sends the kitty keyboard-status query followed by primary-device-attributes as a - /// fallback. A PDA response proves that the terminal answered but does not prove that keyboard - /// enhancement is unsupported until the bounded wait has expired; flags that arrive later in - /// the same deadline must still win. - pub(crate) fn keyboard_enhancement_supported(timeout: Duration) -> io::Result> { + /// Keeping these queries batched avoids paying one timeout per unsupported capability before + /// the first frame can render. + pub(crate) fn startup( + timeout: Duration, + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> io::Result { let mut tty = Tty::open()?; - tty.write_all(b"\x1B[?u\x1B[c")?; - read_keyboard_enhancement_supported(&mut tty, timeout) + match keyboard_probe { + StartupKeyboardEnhancementProbe::Query => { + tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\\x1B[?u\x1B[c")?; + } + StartupKeyboardEnhancementProbe::Skip => { + tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + } + } + read_startup_probe(&mut tty, timeout, keyboard_probe) } /// Reads available terminal bytes until `parse` recognizes a probe response or time expires. @@ -265,42 +275,95 @@ mod imp { } } - /// Reads keyboard-enhancement responses while giving flags the full bounded window to arrive. - fn read_keyboard_enhancement_supported( + fn read_startup_probe( tty: &mut Tty, timeout: Duration, - ) -> io::Result> { + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> io::Result { let deadline = Instant::now() + timeout; let mut buffer = Vec::new(); - let mut saw_supported = false; - let mut saw_unsupported_fallback = false; + let mut probe = StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + }; + let mut saw_supported_keyboard = false; loop { tty.read_available(&mut buffer)?; - match parse_keyboard_enhancement_support(&buffer) { - KeyboardProbeState::SupportedAndFallback => return Ok(Some(true)), - KeyboardProbeState::Supported => saw_supported = true, - KeyboardProbeState::UnsupportedFallback => saw_unsupported_fallback = true, - KeyboardProbeState::Pending => {} - } - if saw_supported && saw_unsupported_fallback { - return Ok(Some(true)); + update_startup_probe( + &mut probe, + &mut saw_supported_keyboard, + &buffer, + keyboard_probe, + ); + if startup_probe_complete(&probe, keyboard_probe) { + return Ok(probe); } let now = Instant::now(); if now >= deadline { - if saw_supported { - return Ok(Some(true)); - } - return Ok(saw_unsupported_fallback.then_some(false)); + finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard); + return Ok(probe); } if !tty.poll_readable(deadline.saturating_duration_since(now))? { - if saw_supported { - return Ok(Some(true)); - } - return Ok(saw_unsupported_fallback.then_some(false)); + finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard); + return Ok(probe); } } } + fn update_startup_probe( + probe: &mut StartupProbe, + saw_supported_keyboard: &mut bool, + buffer: &[u8], + keyboard_probe: StartupKeyboardEnhancementProbe, + ) { + if probe.cursor_position.is_none() { + probe.cursor_position = parse_cursor_position(buffer); + } + if probe.default_colors.is_none() { + probe.default_colors = parse_default_colors(buffer); + } + if keyboard_probe == StartupKeyboardEnhancementProbe::Skip + || probe.keyboard_enhancement_supported.is_some() + { + return; + } + match parse_keyboard_enhancement_support(buffer) { + KeyboardProbeState::SupportedAndFallback => { + probe.keyboard_enhancement_supported = Some(true); + } + KeyboardProbeState::Supported => { + *saw_supported_keyboard = true; + } + KeyboardProbeState::UnsupportedFallback => { + probe.keyboard_enhancement_supported = Some(false); + } + KeyboardProbeState::Pending => {} + } + } + + fn startup_probe_complete( + probe: &StartupProbe, + keyboard_probe: StartupKeyboardEnhancementProbe, + ) -> bool { + probe.cursor_position.is_some() + && probe.default_colors.is_some() + && (keyboard_probe == StartupKeyboardEnhancementProbe::Skip + || probe.keyboard_enhancement_supported.is_some()) + } + + fn finish_startup_probe( + probe: &mut StartupProbe, + keyboard_probe: StartupKeyboardEnhancementProbe, + saw_supported_keyboard: bool, + ) { + if keyboard_probe == StartupKeyboardEnhancementProbe::Query + && probe.keyboard_enhancement_supported.is_none() + { + probe.keyboard_enhancement_supported = saw_supported_keyboard.then_some(true); + } + } + fn parse_cursor_position(buffer: &[u8]) -> Option { for start in find_all_subslices(buffer, b"\x1B[") { let rest = &buffer[start + 2..]; @@ -382,11 +445,12 @@ mod imp { /// Parser state for the keyboard enhancement probe. /// - /// `UnsupportedFallback` records that a primary-device-attributes response arrived, but the - /// caller should keep waiting until the deadline because a later keyboard-flags response is - /// more specific. `Supported` records that keyboard flags arrived, but the caller should still - /// drain the PDA fallback response if it arrives before the deadline so those bytes do not leak - /// into the normal event stream. + /// `UnsupportedFallback` records that a primary-device-attributes response arrived without + /// keyboard flags. Startup treats that as unsupported immediately, matching crossterm's + /// previous behavior and avoiding a fixed delay in terminals without the keyboard protocol. + /// `Supported` records that keyboard flags arrived, but the caller should still drain the PDA + /// fallback response if it arrives before the deadline so those bytes do not leak into the + /// normal event stream. #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum KeyboardProbeState { Pending, @@ -556,6 +620,38 @@ mod imp { KeyboardProbeState::Pending ); } + + #[test] + fn startup_probe_parses_batched_terminal_responses() { + let mut probe = StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + }; + let mut saw_supported_keyboard = false; + update_startup_probe( + &mut probe, + &mut saw_supported_keyboard, + b"\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u", + StartupKeyboardEnhancementProbe::Query, + ); + + assert_eq!( + probe, + StartupProbe { + cursor_position: Some(Position { x: 9, y: 19 }), + default_colors: Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }), + keyboard_enhancement_supported: Some(true), + } + ); + assert!(startup_probe_complete( + &probe, + StartupKeyboardEnhancementProbe::Query + )); + } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 2d22e161fa..32123b787c 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -63,6 +63,11 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +pub(crate) struct InitializedTerminal { + pub(crate) terminal: Terminal, + pub(crate) enhanced_keys_supported: bool, +} + pub(crate) fn running_in_vscode_terminal() -> bool { keyboard_modes::running_in_vscode_terminal() } @@ -334,7 +339,7 @@ fn flush_terminal_input_buffer() { pub(crate) fn flush_terminal_input_buffer() {} /// Initialize the terminal (inline viewport; history stays in normal scrollback) -pub fn init() -> Result { +pub(crate) fn init() -> Result { if !stdin().is_terminal() { return Err(std::io::Error::other("stdin is not a terminal")); } @@ -351,20 +356,57 @@ pub fn init() -> Result { let backend = CrosstermBackend::new(stdout()); #[cfg(unix)] - let cursor_pos = - match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) { - Ok(Some(pos)) => pos, - Ok(None) => { - tracing::warn!("initial cursor position probe timed out; defaulting to origin"); - Position { x: 0, y: 0 } + let startup_probe = { + use crate::terminal_probe::StartupKeyboardEnhancementProbe; + + let started_at = std::time::Instant::now(); + let keyboard_probe = if keyboard_modes::keyboard_enhancement_disabled() { + StartupKeyboardEnhancementProbe::Skip + } else { + StartupKeyboardEnhancementProbe::Query + }; + match crate::terminal_probe::startup(crate::terminal_probe::DEFAULT_TIMEOUT, keyboard_probe) + { + Ok(probe) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + cursor_position = probe.cursor_position.is_some(), + default_colors = probe.default_colors.is_some(), + keyboard_enhancement_supported = ?probe.keyboard_enhancement_supported, + "terminal startup probes completed" + ); + probe } Err(err) => { tracing::warn!( - "failed to read initial cursor position; defaulting to origin: {err}" + duration_ms = %started_at.elapsed().as_millis(), + "terminal startup probes failed: {err}" ); - Position { x: 0, y: 0 } + crate::terminal_probe::StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + } } - }; + } + }; + + #[cfg(unix)] + crate::terminal_palette::set_default_colors_from_startup_probe(startup_probe.default_colors); + + #[cfg(unix)] + let cursor_pos = match startup_probe.cursor_position { + Some(pos) => pos, + None => { + tracing::warn!("initial cursor position probe timed out; defaulting to origin"); + Position { x: 0, y: 0 } + } + }; + + #[cfg(unix)] + let enhanced_keys_supported = startup_probe + .keyboard_enhancement_supported + .unwrap_or(/*default*/ false); #[cfg(not(unix))] let mut backend = CrosstermBackend::new(stdout()); @@ -372,8 +414,15 @@ pub fn init() -> Result { #[cfg(not(unix))] let cursor_pos = cursor_position_with_crossterm(&mut backend); + #[cfg(not(unix))] + let enhanced_keys_supported = + !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; - Ok(tui) + Ok(InitializedTerminal { + terminal: tui, + enhanced_keys_supported, + }) } #[cfg(not(unix))] @@ -384,13 +433,6 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos }) } -#[cfg(unix)] -fn detect_keyboard_enhancement_supported() -> bool { - crate::terminal_probe::keyboard_enhancement_supported(crate::terminal_probe::DEFAULT_TIMEOUT) - .unwrap_or(/*default*/ None) - .unwrap_or(/*default*/ false) -} - #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation @@ -461,14 +503,10 @@ where } impl Tui { - pub fn new(terminal: Terminal) -> Self { + pub fn new(terminal: Terminal, enhanced_keys_supported: bool) -> Self { let (draw_tx, _) = broadcast::channel(1); let frame_requester = FrameRequester::new(draw_tx.clone()); - // Detect keyboard enhancement support before any EventStream is created so the - // crossterm poller can acquire its lock without contention. - let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() - && detect_keyboard_enhancement_supported(); // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); From 82061660ae2adf10dfd5ea5286f5871a8199fdf3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 09:57:54 -0700 Subject: [PATCH 060/114] [codex] Remove legacy shell output formatting paths (#22706) ## Why The client and tool pipeline still carried compatibility code for legacy structured shell output. Current shell and apply_patch responses are already plain text for model consumption, so keeping a JSON-serialization path plus shell-item rewrite logic makes the request formatter and tests preserve a format we do not need anymore. ## What Changed - Removed the client-side shell output rewrite from `core/src/client_common.rs`. - Removed the structured exec-output formatter and the shell `freeform` switch so tool emitters use one model-facing formatter. - Collapsed apply_patch/shell serialization tests around the remaining plain-text output expectations and removed duplicate one-variant parameterized cases. - Kept the `ApplyPatchModelOutput::ShellCommandViaHeredoc` compatibility input shape, but no longer treats it as a separate output-format mode. ## Validation - `cargo test -p codex-core client_common` - `cargo test -p codex-core shell_serialization` - `cargo test -p codex-core apply_patch_cli` - `just fix -p codex-core` ## Documentation No external Codex documentation update is needed. --- codex-rs/core/src/client_common.rs | 111 +------ codex-rs/core/src/client_common_tests.rs | 63 ---- codex-rs/core/src/tools/events.rs | 16 +- codex-rs/core/src/tools/handlers/shell.rs | 9 +- .../src/tools/handlers/shell/shell_command.rs | 1 - codex-rs/core/src/tools/mod.rs | 42 +-- codex-rs/core/tests/common/responses.rs | 15 - codex-rs/core/tests/common/test_codex.rs | 27 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 305 ++++++++---------- codex-rs/core/tests/suite/hooks.rs | 19 +- .../core/tests/suite/shell_serialization.rs | 285 +++------------- 11 files changed, 186 insertions(+), 707 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2061d53c80..d9edadbed4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -2,13 +2,10 @@ pub use codex_api::ResponseEvent; use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; -use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; use codex_tools::ToolSpec; use futures::Stream; -use serde::Deserialize; use serde_json::Value; -use std::collections::HashSet; use std::pin::Pin; use std::task::Context; use std::task::Poll; @@ -64,116 +61,10 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input = self.input.clone(); - - // when using the *Freeform* apply_patch tool specifically, tool outputs - // should be structured text, not json. Do NOT reserialize when using - // the Function tool - note that this differs from the check above for - // instructions. We declare the result as a named variable for clarity. - let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool { - ToolSpec::Freeform(f) => f.name == "apply_patch", - _ => false, - }); - if is_freeform_apply_patch_tool_present { - reserialize_shell_outputs(&mut input); - } - - input + self.input.clone() } } -fn reserialize_shell_outputs(items: &mut [ResponseItem]) { - let mut shell_call_ids: HashSet = HashSet::new(); - - items.iter_mut().for_each(|item| match item { - ResponseItem::LocalShellCall { call_id, id, .. } => { - if let Some(identifier) = call_id.clone().or_else(|| id.clone()) { - shell_call_ids.insert(identifier); - } - } - ResponseItem::CustomToolCall { - id: _, - status: _, - call_id, - name, - input: _, - } => { - if name == "apply_patch" { - shell_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCall { name, call_id, .. } - if is_shell_tool_name(name) || name == "apply_patch" => - { - shell_call_ids.insert(call_id.clone()); - } - ResponseItem::FunctionCallOutput { - call_id, output, .. - } - | ResponseItem::CustomToolCallOutput { - call_id, output, .. - } => { - if shell_call_ids.remove(call_id) - && let Some(structured) = output - .text_content() - .and_then(parse_structured_shell_output) - { - output.body = FunctionCallOutputBody::Text(structured); - } - } - _ => {} - }) -} - -fn is_shell_tool_name(name: &str) -> bool { - name == "shell" -} - -#[derive(Deserialize)] -struct ExecOutputJson { - output: String, - metadata: ExecOutputMetadataJson, -} - -#[derive(Deserialize)] -struct ExecOutputMetadataJson { - exit_code: i32, - duration_seconds: f32, -} - -fn parse_structured_shell_output(raw: &str) -> Option { - let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?; - Some(build_structured_output(&parsed)) -} - -fn build_structured_output(parsed: &ExecOutputJson) -> String { - let mut sections = Vec::new(); - sections.push(format!("Exit code: {}", parsed.metadata.exit_code)); - sections.push(format!( - "Wall time: {} seconds", - parsed.metadata.duration_seconds - )); - - let mut output = parsed.output.clone(); - if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) { - sections.push(format!("Total output lines: {total_lines}")); - output = stripped.to_string(); - } - - sections.push("Output:".to_string()); - sections.push(output); - - sections.join("\n") -} - -fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { - let after_prefix = output.strip_prefix("Total output lines: ")?; - let (total_segment, remainder) = after_prefix.split_once('\n')?; - let total_lines = total_segment.parse::().ok()?; - let remainder = remainder.strip_prefix('\n').unwrap_or(remainder); - Some((remainder, total_lines)) -} - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, /// Signals the mapper task that the consumer stopped polling before the diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index f67e4f1fd8..45d4334222 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -3,7 +3,6 @@ use codex_api::ResponsesApiRequest; use codex_api::TextControls; use codex_api::create_text_param_for_request; use codex_protocol::config_types::ServiceTier; -use codex_protocol::models::FunctionCallOutputPayload; use pretty_assertions::assert_eq; use super::*; @@ -166,65 +165,3 @@ fn serializes_flex_service_tier_when_set() { Some("flex") ); } - -#[test] -fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { - let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#; - let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello"; - let mut items = vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ]; - - reserialize_shell_outputs(&mut items); - - assert_eq!( - items, - vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ] - ); -} diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 51f1833f07..747143ff69 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -116,7 +116,6 @@ pub(crate) enum ToolEmitter { cwd: AbsolutePathBuf, source: ExecCommandSource, parsed_cmd: Vec, - freeform: bool, }, ApplyPatch { changes: HashMap, @@ -132,19 +131,13 @@ pub(crate) enum ToolEmitter { } impl ToolEmitter { - pub fn shell( - command: Vec, - cwd: AbsolutePathBuf, - source: ExecCommandSource, - freeform: bool, - ) -> Self { + pub fn shell(command: Vec, cwd: AbsolutePathBuf, source: ExecCommandSource) -> Self { let parsed_cmd = parse_command(&command); Self::Shell { command, cwd, source, parsed_cmd, - freeform, } } @@ -328,12 +321,7 @@ impl ToolEmitter { output: &ExecToolCallOutput, ctx: ToolEventCtx<'_>, ) -> String { - match self { - Self::Shell { freeform: true, .. } => { - super::format_exec_output_for_model_freeform(output, ctx.turn.truncation_policy) - } - _ => super::format_exec_output_for_model_structured(output, ctx.turn.truncation_policy), - } + super::format_exec_output_for_model(output, ctx.turn.truncation_policy) } pub async fn finish( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c8c93bd6c7..2c69566b0c 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -52,7 +52,6 @@ struct RunExecLikeArgs { turn: Arc, tracker: crate::tools::context::SharedTurnDiffTracker, call_id: String, - freeform: bool, shell_runtime_backend: ShellRuntimeBackend, } @@ -68,7 +67,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { turn, tracker, call_id, - freeform: true, shell_runtime_backend: self.shell_runtime_backend(), }) .await diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 5cd943e199..dd63116353 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -23,7 +23,6 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; pub use router::ToolRouter; -use serde::Serialize; // Telemetry preview limits: keep log events smaller than model budgets. pub(crate) const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024; // 2 KiB @@ -60,46 +59,7 @@ pub(crate) fn tool_user_shell_type( /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. -pub fn format_exec_output_for_model_structured( - exec_output: &ExecToolCallOutput, - truncation_policy: TruncationPolicy, -) -> String { - let ExecToolCallOutput { - exit_code, - duration, - .. - } = exec_output; - - #[derive(Serialize)] - struct ExecMetadata { - exit_code: i32, - duration_seconds: f32, - } - - #[derive(Serialize)] - struct ExecOutput<'a> { - output: &'a str, - metadata: ExecMetadata, - } - - // round to 1 decimal place - let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0; - - let formatted_output = format_exec_output_str(exec_output, truncation_policy); - - let payload = ExecOutput { - output: &formatted_output, - metadata: ExecMetadata { - exit_code: *exit_code, - duration_seconds, - }, - }; - - #[expect(clippy::expect_used)] - serde_json::to_string(&payload).expect("serialize ExecOutput") -} - -pub fn format_exec_output_for_model_freeform( +pub fn format_exec_output_for_model( exec_output: &ExecToolCallOutput, truncation_policy: TruncationPolicy, ) -> String { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 6c1f581f9f..c917436b6d 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -35,8 +35,6 @@ use wiremock::http::HeaderValue; use wiremock::matchers::method; use wiremock::matchers::path_regex; -use crate::test_codex::ApplyPatchModelOutput; - #[derive(Debug, Clone)] pub struct ResponseMock { requests: Arc>>, @@ -883,19 +881,6 @@ pub fn ev_local_shell_call(call_id: &str, status: &str, command: Vec<&str>) -> V }) } -pub fn ev_apply_patch_call( - call_id: &str, - patch: &str, - output_type: ApplyPatchModelOutput, -) -> Value { - match output_type { - ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch), - ApplyPatchModelOutput::ShellCommandViaHeredoc => { - ev_apply_patch_shell_command_call_via_heredoc(call_id, patch) - } - } -} - /// Convenience: SSE event for an `apply_patch` custom tool call with raw patch /// text. This mirrors the payload produced by the Responses API when the model /// invokes `apply_patch` directly. diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 947111d3fc..2007104868 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -184,20 +184,12 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result String { - // Box the awaited output helpers so callers do not inline request - // capture and response parsing into their own async state. - match output_type { - ApplyPatchModelOutput::Freeform => { - Box::pin(self.custom_tool_call_output(call_id)).await - } - ApplyPatchModelOutput::ShellCommandViaHeredoc => { - Box::pin(self.function_call_stdout(call_id)).await - } - } + pub async fn apply_patch_output(&self, call_id: &str) -> String { + self.custom_tool_call_output(call_id).await } } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 9185b8a7bd..05379517e5 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -3,8 +3,8 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use core_test_support::responses::ev_apply_patch_call; 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 pretty_assertions::assert_eq; @@ -45,7 +45,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use serde_json::json; -use test_case::test_case; use wiremock::Mock; use wiremock::Respond; use wiremock::ResponseTemplate; @@ -182,11 +181,35 @@ pub async fn mount_apply_patch( call_id: &str, patch: &str, assistant_msg: &str, - output_type: ApplyPatchModelOutput, ) { mount_sse_sequence( harness.server(), - apply_patch_responses(call_id, patch, assistant_msg, output_type), + apply_patch_responses( + call_id, + patch, + assistant_msg, + ev_apply_patch_custom_tool_call, + ), + ) + .await; +} + +async fn mount_apply_patch_model_output( + harness: &TestCodexHarness, + call_id: &str, + patch: &str, + assistant_msg: &str, + model_output: ApplyPatchModelOutput, +) { + let apply_patch_call = match model_output { + ApplyPatchModelOutput::ShellCommandViaHeredoc => { + ev_apply_patch_shell_command_call_via_heredoc + } + }; + + mount_sse_sequence( + harness.server(), + apply_patch_responses(call_id, patch, assistant_msg, apply_patch_call), ) .await; } @@ -195,12 +218,12 @@ fn apply_patch_responses( call_id: &str, patch: &str, assistant_msg: &str, - output_type: ApplyPatchModelOutput, + apply_patch_call: fn(&str, &str) -> serde_json::Value, ) -> Vec { vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_call(call_id, patch, output_type), + apply_patch_call(call_id, patch), ev_completed("resp-1"), ]), sse(vec![ @@ -231,20 +254,11 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - let patch = "*** Begin Patch\n*** Add File: helper-alias.txt\n+hello\n*** End Patch"; let call_id = "apply-helper-alias"; - mount_apply_patch( - &harness, - call_id, - patch, - "done", - ApplyPatchModelOutput::Freeform, - ) - .await; + mount_apply_patch(&harness, call_id, patch, "done").await; harness.submit("please apply helper alias patch").await?; - let out = harness - .apply_patch_output(call_id, ApplyPatchModelOutput::Freeform) - .await; + let out = harness.apply_patch_output(call_id).await; assert_regex_match( r"(?s)^Exit code: 0.*Success\. Updated the following files:\nA helper-alias\.txt\n?$", &out, @@ -255,10 +269,7 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_cli_multiple_operations_integration( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_multiple_operations_integration() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.4")).await?; @@ -270,11 +281,11 @@ async fn apply_patch_cli_multiple_operations_integration( let patch = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"; let call_id = "apply-multi-ops"; - mount_apply_patch(&harness, call_id, patch, "done", output_type).await; + mount_apply_patch(&harness, call_id, patch, "done").await; harness.submit("please apply multi-ops patch").await?; - let out = harness.apply_patch_output(call_id, output_type).await; + let out = harness.apply_patch_output(call_id).await; let expected = r"(?s)^Exit code: 0 Wall time: [0-9]+(?:\.[0-9]+)? seconds @@ -297,9 +308,7 @@ D delete.txt } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_multiple_chunks() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -310,7 +319,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> let patch = "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"; let call_id = "apply-multi-chunks"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply multi-chunk patch").await?; @@ -322,11 +331,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_moves_file_to_new_directory( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_moves_file_to_new_directory() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -335,7 +340,7 @@ async fn apply_patch_cli_moves_file_to_new_directory( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"; let call_id = "apply-move"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply move patch").await?; @@ -348,11 +353,7 @@ async fn apply_patch_cli_moves_file_to_new_directory( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_updates_file_appends_trailing_newline( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_updates_file_appends_trailing_newline() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -363,7 +364,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( let patch = "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"; let call_id = "apply-append-nl"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply newline patch").await?; @@ -374,11 +375,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_insert_only_hunk_modifies_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_insert_only_hunk_modifies_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -389,7 +386,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( let patch = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"; let call_id = "apply-insert-only"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("insert lines via apply_patch").await?; @@ -401,11 +398,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_move_overwrites_existing_destination( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_move_overwrites_existing_destination() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -417,7 +410,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"; let call_id = "apply-move-overwrite"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply move overwrite patch").await?; @@ -430,11 +423,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_move_without_content_change_has_no_turn_diff() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -445,7 +434,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/name.txt\n@@\n same\n*** End Patch"; let call_id = "apply-move-no-change"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; submit_without_wait(&harness, "rename without content change").await?; @@ -467,11 +456,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_add_overwrites_existing_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_add_overwrites_existing_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -480,7 +465,7 @@ async fn apply_patch_cli_add_overwrites_existing_file( let patch = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"; let call_id = "apply-add-overwrite"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply add overwrite patch").await?; @@ -492,22 +477,18 @@ async fn apply_patch_cli_add_overwrites_existing_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_invalid_hunk_header( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_invalid_hunk_header() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"; let call_id = "apply-invalid-header"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply invalid header patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -521,11 +502,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_reports_missing_context( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_reports_missing_context() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -535,11 +512,11 @@ async fn apply_patch_cli_reports_missing_context( let patch = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"; let call_id = "apply-missing-context"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply missing context patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -554,22 +531,18 @@ async fn apply_patch_cli_reports_missing_context( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_reports_missing_target_file( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_reports_missing_target_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"; let call_id = "apply-missing-file"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("attempt to update a missing file").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), "expected verification failure message" @@ -587,22 +560,18 @@ async fn apply_patch_cli_reports_missing_target_file( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_delete_missing_file_reports_error( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_delete_missing_file_reports_error() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"; let call_id = "apply-delete-missing"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("attempt to delete missing file").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("apply_patch verification failed"), @@ -621,20 +590,18 @@ async fn apply_patch_cli_delete_missing_file_reports_error( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_rejects_empty_patch() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let patch = "*** Begin Patch\n*** End Patch"; let call_id = "apply-empty"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply empty patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains("patch rejected: empty patch"), "expected rejection for empty patch: {out}" @@ -643,11 +610,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_delete_directory_reports_verification_error( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_delete_directory_reports_verification_error() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -656,22 +619,18 @@ async fn apply_patch_cli_delete_directory_reports_verification_error( let patch = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"; let call_id = "apply-delete-dir"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("delete a directory via apply_patch").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!(out.contains("apply_patch verification failed")); assert!(out.contains("Failed to read")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_path_traversal_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_path_traversal_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -687,7 +646,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( let patch = "*** Begin Patch\n*** Add File: ../escape.txt\n+outside\n*** End Patch"; let call_id = "apply-path-traversal"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness .submit_with_permission_profile( @@ -696,7 +655,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains( "patch rejected: writing outside of the project; rejected by user approval settings" @@ -712,10 +671,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn intercepted_apply_patch_verification_uses_local_sandbox( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn intercepted_apply_patch_verification_uses_local_sandbox() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!(Ok(()), "symlink setup needs local filesystem link creation"); @@ -735,7 +691,14 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( *** End Patch"# ); let call_id = "apply-sandboxed-read"; - mount_apply_patch(&harness, call_id, &patch, "fail", model_output).await; + mount_apply_patch_model_output( + &harness, + call_id, + &patch, + "fail", + ApplyPatchModelOutput::ShellCommandViaHeredoc, + ) + .await; harness .submit_with_permission_profile( @@ -744,7 +707,11 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.function_call_stdout(call_id).await; + assert!( + serde_json::from_str::(&out).is_err(), + "expected heredoc apply_patch output to be plain text" + ); assert!( out.contains("apply_patch verification failed"), "expected sandboxed verification failure: {out}" @@ -762,11 +729,7 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), @@ -810,7 +773,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace *** End Patch"# ); let call_id = "apply-symlink-escape"; - mount_apply_patch(&harness, call_id, &patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, &patch, "fail").await; harness .submit_with_permission_profile( @@ -819,7 +782,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert_eq!( std::fs::read_to_string(&outside_file)?, original_contents, @@ -831,11 +794,7 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] -async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), @@ -871,7 +830,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( *** End Patch"# ); let call_id = "apply-hard-link"; - mount_apply_patch(&harness, call_id, &patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, &patch, "ok").await; harness .submit_with_permission_profile( @@ -880,7 +839,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; if cfg!(windows) { assert!( out.contains("patch rejected: writing outside of the project"), @@ -933,11 +892,7 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -955,7 +910,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( let patch = "*** Begin Patch\n*** Update File: stay.txt\n*** Move to: ../escape-move.txt\n@@\n-from\n+to\n*** End Patch"; let call_id = "apply-move-traversal"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness .submit_with_permission_profile( @@ -964,7 +919,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( ) .await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!( out.contains( "patch rejected: writing outside of the project; rejected by user approval settings" @@ -980,11 +935,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_verification_failure_has_no_side_effects( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_verification_failure_has_no_side_effects() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -993,7 +944,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( let call_id = "apply-partial-no-side-effects"; let patch = "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"; - mount_apply_patch(&harness, call_id, patch, "failed", model_output).await; + mount_apply_patch(&harness, call_id, patch, "failed").await; harness.submit("attempt partial apply patch").await?; @@ -1393,14 +1344,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest let call_id = "apply-nested-cwd-repo-relative"; let patch = "*** Begin Patch\n*** Update File: ../repo.txt\n@@\n-before\n+after\n*** End Patch"; - mount_apply_patch( - &harness, - call_id, - patch, - "updated repo-relative path", - ApplyPatchModelOutput::Freeform, - ) - .await; + mount_apply_patch(&harness, call_id, patch, "updated repo-relative path").await; submit_without_wait(&harness, "update file outside nested cwd but inside repo").await?; @@ -1485,10 +1429,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1497,18 +1438,36 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( let patch_inner = format!("*** Begin Patch\n*** Add File: {file_name}\n+lenient\n*** End Patch\n"); let call_id = "apply-lenient"; - mount_apply_patch(&harness, call_id, patch_inner.as_str(), "ok", model_output).await; + mount_apply_patch_model_output( + &harness, + call_id, + patch_inner.as_str(), + "ok", + ApplyPatchModelOutput::ShellCommandViaHeredoc, + ) + .await; harness.submit("apply lenient heredoc patch").await?; + let out = harness.function_call_stdout(call_id).await; + assert!( + serde_json::from_str::(&out).is_err(), + "expected heredoc apply_patch output to be plain text" + ); + assert!( + out.contains("Success. Updated the following files:"), + "expected successful apply_patch output: {out}" + ); + assert!( + out.contains(&format!("A {file_name}")), + "expected created file in apply_patch output: {out}" + ); assert_eq!(harness.read_file_text(file_name).await?, "lenient\n"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> { +async fn apply_patch_cli_end_of_file_anchor() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1517,7 +1476,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) let patch = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"; let call_id = "apply-eof"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply EOF-anchored patch").await?; assert_eq!(harness.read_file_text("tail.txt").await?, "alpha\nend\n"); @@ -1525,11 +1484,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_cli_missing_second_chunk_context_rejected( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_cli_missing_second_chunk_context_rejected() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1540,11 +1495,11 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( let patch = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"; let call_id = "apply-missing-ctx-2nd"; - mount_apply_patch(&harness, call_id, patch, "fail", model_output).await; + mount_apply_patch(&harness, call_id, patch, "fail").await; harness.submit("apply missing context second chunk").await?; - let out = harness.apply_patch_output(call_id, model_output).await; + let out = harness.apply_patch_output(call_id).await; assert!(out.contains("apply_patch verification failed")); assert!( out.contains("Failed to find expected lines in"), @@ -1559,11 +1514,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_emits_turn_diff_event_with_unified_diff( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1573,7 +1524,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( let call_id = "apply-diff-event"; let file = "udiff.txt"; let patch = format!("*** Begin Patch\n*** Add File: {file}\n+hello\n*** End Patch\n"); - mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok").await; submit_without_wait(&harness, "emit diff").await?; @@ -1789,11 +1740,7 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_change_context_disambiguates_target( - model_output: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_change_context_disambiguates_target() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -1805,7 +1752,7 @@ async fn apply_patch_change_context_disambiguates_target( let patch = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"; let call_id = "apply-ctx"; - mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; + mount_apply_patch(&harness, call_id, patch, "ok").await; harness.submit("apply with change_context").await?; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 53649afff8..ef3918e189 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3650,22 +3650,9 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() let tool_response = hook_inputs[0]["tool_response"] .as_str() .context("apply_patch tool_response should be a string")?; - let mut parsed_tool_response = serde_json::from_str::(tool_response)?; - if let Some(metadata) = parsed_tool_response - .get_mut("metadata") - .and_then(Value::as_object_mut) - { - let _ = metadata.remove("duration_seconds"); - } - assert_eq!( - parsed_tool_response, - serde_json::json!({ - "output": "Success. Updated the following files:\nA post_tool_use_apply_patch.txt\n", - "metadata": { - "exit_code": 0, - }, - }) - ); + assert!(tool_response.starts_with("Exit code: 0")); + assert!(tool_response.contains("Success. Updated the following files:")); + assert!(tool_response.contains("A post_tool_use_apply_patch.txt")); Ok(()) } diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index dbe4957ecb..2295798daa 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -12,16 +12,12 @@ use core_test_support::responses::mount_sse_sequence; 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::ApplyPatchModelOutput; -use core_test_support::test_codex::ShellModelOutput; -use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; use serde_json::json; use std::fs; -use test_case::test_case; use crate::suite::apply_patch_cli::apply_patch_harness; use crate::suite::apply_patch_cli::mount_apply_patch; @@ -38,135 +34,68 @@ const FIXTURE_JSON: &str = r#"{ } "#; -fn shell_responses( - call_id: &str, - command: Vec<&str>, - output_type: ShellModelOutput, -) -> Result> { - match output_type { - ShellModelOutput::ShellCommand => { - let command = shlex::try_join(command)?; - let parameters = json!({ - "command": command, - "timeout_ms": 2_000, - }); - Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - "shell_command", - &serde_json::to_string(¶meters)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]) - } - } -} - -fn configure_shell_model( - builder: TestCodexBuilder, - output_type: ShellModelOutput, -) -> TestCodexBuilder { - match output_type { - ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), - } +fn shell_responses(call_id: &str, command: Vec<&str>) -> Result> { + let command = shlex::try_join(command)?; + let parameters = json!({ + "command": command, + "timeout_ms": 2_000, + }); + Ok(vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(¶meters)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_is_structured_with_freeform_apply_patch( - output_type: ShellModelOutput, -) -> Result<()> { +async fn shell_output_preserves_fixture_json_as_freeform() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); - let test = builder.build(&server).await?; - - let call_id = "shell-structured"; - let responses = shell_responses(call_id, vec!["/bin/echo", "freeform shell"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the structured shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("structured shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("structured output string"); - - assert!( - serde_json::from_str::(output).is_err(), - "expected structured shell output to be plain text", - ); - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -freeform shell -?$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_structures_fixture_with_serialization( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); fs::write(&fixture_path, FIXTURE_JSON)?; let fixture_path_str = fixture_path.to_string_lossy().to_string(); - let call_id = "shell-structured-fixture"; + let call_id = "shell-freeform-fixture"; let responses = shell_responses( call_id, vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()], - output_type, )?; let mock = mount_sse_sequence(&server, responses).await; test.submit_turn_with_permission_profile( - "read the fixture JSON with structured output", + "read the fixture JSON with shell output", PermissionProfile::Disabled, ) .await?; - let req = mock - .last_request() - .expect("structured output request recorded"); + let req = mock.last_request().expect("shell output request recorded"); let output_item = req.function_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) - .expect("structured output string"); + .expect("shell output string"); assert!( serde_json::from_str::(output).is_err(), - "expected structured output to be plain text" + "expected shell output to be plain text" ); let (header, body) = output .split_once("Output:\n") - .expect("structured output contains an Output section"); + .expect("shell output contains an Output section"); assert_regex_match( r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds$", header.trim_end(), @@ -180,34 +109,26 @@ async fn shell_output_structures_fixture_with_serialization( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_for_freeform_tool_records_duration( - output_type: ShellModelOutput, -) -> Result<()> { +async fn shell_output_records_duration() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model(test_codex(), output_type); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let call_id = "shell-structured"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"], output_type)?; + let call_id = "shell-freeform"; + let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"])?; let mock = mount_sse_sequence(&server, responses).await; - test.submit_turn_with_permission_profile( - "run the structured shell command", - PermissionProfile::Disabled, - ) - .await?; + test.submit_turn_with_permission_profile("run the shell command", PermissionProfile::Disabled) + .await?; - let req = mock - .last_request() - .expect("structured output request recorded"); + let req = mock.last_request().expect("shell output request recorded"); let output_item = req.function_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) - .expect("structured output string"); + .expect("shell output string"); let expected_pattern = r#"(?s)^Exit code: 0 Wall time: [0-9]+(?:\.[0-9]+)? seconds @@ -221,7 +142,7 @@ $"#; .captures(output) .and_then(|caps| caps.get(1)) .and_then(|value| value.as_str().parse::().ok()) - .expect("expected structured shell output to contain wall time seconds"); + .expect("expected shell output to contain wall time seconds"); assert!( wall_time_seconds > 0.1, "expected wall time to be greater than zero seconds, got {wall_time_seconds}" @@ -231,53 +152,7 @@ $"#; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_output_is_structured( - output_type: ApplyPatchModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-structured"; - let file_name = "structured.txt"; - let patch = format!( - r#"*** Begin Patch -*** Add File: {file_name} -+from custom tool -*** End Patch -"# - ); - mount_apply_patch(&harness, call_id, &patch, "done", output_type).await; - - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via custom tool", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id, output_type).await; - - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -A {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_creates_file( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_creates_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -287,7 +162,7 @@ async fn apply_patch_custom_tool_call_creates_file( let patch = format!( "*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n" ); - mount_apply_patch(&harness, call_id, &patch, "apply_patch done", output_type).await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await; harness .test() @@ -297,7 +172,7 @@ async fn apply_patch_custom_tool_call_creates_file( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_pattern = format!( r"(?s)^Exit code: 0 @@ -319,10 +194,7 @@ A {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_updates_existing_file( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -333,14 +205,7 @@ async fn apply_patch_custom_tool_call_updates_existing_file( let patch = format!( "*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n" ); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch update done", - output_type, - ) - .await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await; harness .test() @@ -350,7 +215,7 @@ async fn apply_patch_custom_tool_call_updates_existing_file( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_pattern = format!( r"(?s)^Exit code: 0 @@ -369,10 +234,7 @@ M {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_custom_tool_call_reports_failure_output( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; @@ -382,14 +244,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( let patch = format!( "*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n" ); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch failure done", - output_type, - ) - .await; + mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await; harness .test() @@ -399,7 +254,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( ) .await?; - let output = harness.apply_patch_output(call_id, output_type).await; + let output = harness.apply_patch_output(call_id).await; let expected_output = format!( "apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)", @@ -411,49 +266,7 @@ async fn apply_patch_custom_tool_call_reports_failure_output( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Freeform)] -async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-function"; - let file_name = "freeform_apply_patch.txt"; - let patch = - format!("*** Begin Patch\n*** Add File: {file_name}\n+via apply_patch\n*** End Patch\n"); - mount_apply_patch( - &harness, - call_id, - &patch, - "apply_patch function done", - output_type, - ) - .await; - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via freeform apply_patch", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id, output_type).await; - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -A {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::ShellCommand)] -async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutput) -> Result<()> { +async fn shell_output_is_freeform_for_nonzero_exit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -461,7 +274,7 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp let test = builder.build(&server).await?; let call_id = "shell-nonzero-exit"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"], output_type)?; + let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"])?; let mock = mount_sse_sequence(&server, responses).await; test.submit_turn_with_permission_profile( From 3f2b7ede0b1b7d9dfc317bd02f15eeed26ed8d72 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 19:25:27 +0200 Subject: [PATCH 061/114] nit: read prompt (#23332) --- .../memories/read/templates/memories/read_path.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/codex-rs/memories/read/templates/memories/read_path.md b/codex-rs/memories/read/templates/memories/read_path.md index 828b30923e..00639c7e8c 100644 --- a/codex-rs/memories/read/templates/memories/read_path.md +++ b/codex-rs/memories/read/templates/memories/read_path.md @@ -25,7 +25,7 @@ Memory layout (general -> specific): - scripts/ (optional helper scripts) - examples/ (optional example outputs) - templates/ (optional templates) - - {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) +- {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) - The paths of these entries can be found in {{ base_path }}/MEMORY.md or {{ base_path }}/rollout_summaries/ as `rollout_path` - These files are append-only `jsonl`: `session_meta.payload.id` identifies the session, `turn_context` marks turn boundaries, `event_msg` is the lightweight status stream, and `response_item` contains actual messages, tool calls, and tool outputs. - For efficient lookup, prefer matching the filename suffix or `session_meta.payload.id`; avoid broad full-content scans unless needed. @@ -57,9 +57,6 @@ How to decide whether to verify memory: disruptive, it is acceptable to answer from memory in an interactive turn, but you should say that it is memory-derived, note that it may be stale, and consider offering to refresh it live. -- If a fact is lower-drift and cheap to verify, use judgment: verification is - more important when the fact is central to the answer or especially easy to - confirm. - If a fact is lower-drift and expensive to verify, it is usually fine to answer from memory directly. @@ -72,10 +69,8 @@ When answering from memory without current verification: - If live verification was skipped and a refresh would be useful in the interactive context, consider offering to verify or refresh it live. - Do not present unverified memory-derived facts as confirmed-current. -- For interactive requests, prefer a short refresh offer over silently doing - expensive verification that the user did not ask for. -- When the unverified fact is about prior results, commands, timing, or an - older snapshot, a concrete refresh offer can be especially helpful. +- Prefer a short refresh offer for interactive questions, especially about prior + results, commands, timing, or older snapshots. Memory citation requirements: @@ -88,7 +83,7 @@ Memory citation requirements: MEMORY.md:234-236|note=[responsesapi citation extraction code pointer] -rollout_summaries/2026-02-17T21-23-02-LN3m-weekly_memory_report_pivot_from_git_history.md:10-12|note=[weekly report format] +rollout_summaries/2026-02-17T21-23-02-LN3m-example.md:10-12|note=[weekly report format] 019c6e27-e55b-73d1-87d8-4e01f1f75043 From d335b002127fa849c09beb005e73057c81b80182 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 10:32:33 -0700 Subject: [PATCH 062/114] windows: link MSVC release binaries with static CRT (#22905) ## Why Windows release artifacts currently import `VCRUNTIME140.dll` and `VCRUNTIME140_1.dll`. That becomes observable on clean Windows machines that do not already have the VC++ runtime available globally: - Desktop Store launches can fail after the app relocates `codex.exe` out of `WindowsApps`, which means an MSIX-level VCLibs dependency does not protect the relocated CLI/app-server process. - The npm CLI path reproduces the same missing-DLL startup failure when `System32\vcruntime140_1.dll` is hidden and `PATH` is stripped of incidental fallback copies. In that setup, the existing Windows binary exits with `0xC0000135` / `-1073741515` before Codex code runs. ## What changed - Add `-C target-feature=+crt-static` to the existing MSVC-only Cargo rustflags in `codex-rs/.cargo/config.toml`. - Preserve the existing `/STACK:8388608` linker setting in the same target block. This keeps the change scoped to Windows MSVC builds and avoids altering non-Windows or GNU target behavior. ## Verification I built an x64 Windows release probe with static CRT linkage and the normal 8 MiB stack reserve, then verified: - `dumpbin /dependents codex.exe` no longer reports `VCRUNTIME140.dll` or `VCRUNTIME140_1.dll`. - `dumpbin /headers codex.exe` reports `800000 size of stack reserve`. - With `System32\vcruntime140_1.dll` hidden and `PATH` stripped to Windows system directories only: - the old npm CLI path exits `-1073741515` - the rebuilt static-CRT `codex.exe --version` succeeds with exit code `0` - the rebuilt TUI starts successfully I also confirmed `codex.exe app-server --listen ws://127.0.0.1:0` starts and binds normally with the static-CRT artifact. --- codex-rs/.cargo/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml index 5d5eb8fd6f..8a6f2c11ee 100644 --- a/codex-rs/.cargo/config.toml +++ b/codex-rs/.cargo/config.toml @@ -1,5 +1,5 @@ [target.'cfg(all(windows, target_env = "msvc"))'] -rustflags = ["-C", "link-arg=/STACK:8388608"] +rustflags = ["-C", "link-arg=/STACK:8388608", "-C", "target-feature=+crt-static"] # MSVC emits a warning about code that may trip "Cortex-A53 MPCore processor bug #843419" (see # https://developer.arm.com/documentation/epm048406/latest) which is sometimes emitted by LLVM. From bb43044cbac4351b90d7d3dd52e74f15bc5f4cb0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 14:41:14 -0300 Subject: [PATCH 063/114] fix(tui): show shutdown feedback on exit (#23323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Ctrl+C can take a noticeable amount of time to finish when the TUI is waiting for the app-server thread shutdown path to complete. Before this change, the UI could look like it had not accepted the shutdown request because the composer and cursor remained in their normal interactive state during that wait. This PR makes the accepted shutdown visible immediately. It does not add an artificial sleep or change the shutdown timeout; it only draws one final feedback frame before continuing through the existing shutdown flow. ## What Changed - On `ExitMode::ShutdownFirst`, the TUI now renders shutdown feedback before awaiting the existing thread shutdown future. - The bottom pane disables composer input, which hides the cursor through the existing disabled-input cursor path. - The composer shows `Shutting down...` as the disabled input hint and suppresses footer content so the shutdown acknowledgement is not competing with shortcut/status text. - The logout path uses the same feedback path before shutting down. ## How to Test 1. Start Codex from this branch. 2. Press `Ctrl+C` to request shutdown. 3. If shutdown takes long enough to observe, confirm the composer changes to `› Shutting down...`, the cursor disappears, and no footer hint is rendered below it. 4. Regression check: repeat with text already typed in the composer and confirm the visible row still switches to `Shutting down...` while the draft remains preserved internally until the process exits. Targeted tests: - `cargo test -p codex-tui shutdown_in_progress_disables_input_and_uses_hint_without_footer` - `cargo test -p codex-tui bottom_pane::footer::tests::` ## Local Validation Note `cargo test -p codex-tui` still aborts in `app::tests::discard_side_thread_removes_agent_navigation_entry` with a stack overflow. That same test also failed when run alone locally, and the failure appears unrelated to this shutdown feedback path. --- codex-rs/tui/src/app.rs | 69 +++++++----- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/pets.rs | 16 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 102 +++++++++++++----- codex-rs/tui/src/bottom_pane/mod.rs | 6 ++ ...composer__tests__shutdown_in_progress.snap | 9 ++ codex-rs/tui/src/chatwidget.rs | 4 + 7 files changed, 159 insertions(+), 51 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 07ac30f046..1d72d8d46b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1195,30 +1195,8 @@ See the Codex keymap documentation for supported actions and examples." } // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); - let desired_height = - self.chat_widget.desired_height(tui.terminal.size()?.width); - let mut rendered_area = Rect::default(); - if terminal_resize_reflow_enabled { - tui.draw_with_resize_reflow(desired_height, |frame| { - let area = frame.area(); - rendered_area = area; - self.chat_widget.render(area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(area) { - frame.set_cursor_style(self.chat_widget.cursor_style(area)); - frame.set_cursor_position((x, y)); - } - })?; - } else { - tui.draw(desired_height, |frame| { - let area = frame.area(); - rendered_area = area; - self.chat_widget.render(area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(area) { - frame.set_cursor_style(self.chat_widget.cursor_style(area)); - frame.set_cursor_position((x, y)); - } - })?; - } + let rendered_area = + self.render_chat_widget_frame(tui, terminal_resize_reflow_enabled)?; if self.chat_widget.ambient_pet_image_enabled() { let terminal_size = tui.terminal.size()?; let ambient_pet_area = Rect::new( @@ -1253,6 +1231,49 @@ See the Codex keymap documentation for supported actions and examples." } Ok(AppRunControl::Continue) } + + pub(super) fn show_shutdown_feedback(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.disable_ambient_pet_before_shutdown(tui)?; + self.chat_widget.show_shutdown_in_progress(); + let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); + if terminal_resize_reflow_enabled { + self.handle_draw_pre_render(tui)?; + } + self.chat_widget.pre_draw_tick(); + self.render_chat_widget_frame(tui, terminal_resize_reflow_enabled)?; + Ok(()) + } + + fn render_chat_widget_frame( + &mut self, + tui: &mut tui::Tui, + terminal_resize_reflow_enabled: bool, + ) -> Result { + let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width); + let mut rendered_area = Rect::default(); + if terminal_resize_reflow_enabled { + tui.draw_with_resize_reflow(desired_height, |frame| { + let area = frame.area(); + rendered_area = area; + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); + frame.set_cursor_position((x, y)); + } + })?; + } else { + tui.draw(desired_height, |frame| { + let area = frame.area(); + rendered_area = area; + self.chat_widget.render(area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(area) { + frame.set_cursor_style(self.chat_widget.cursor_style(area)); + frame.set_cursor_position((x, y)); + } + })?; + } + Ok(rendered_area) + } } impl Drop for App { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index efb9a26c63..d1ce849c04 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -296,10 +296,14 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::Exit(mode) => { + if mode == ExitMode::ShutdownFirst { + self.show_shutdown_feedback(tui)?; + } return Ok(self.handle_exit_mode(app_server, mode).await); } AppEvent::Logout => match app_server.logout_account().await { Ok(()) => { + self.show_shutdown_feedback(tui)?; return Ok(self .handle_exit_mode(app_server, ExitMode::ShutdownFirst) .await); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index fe6e90673b..eef38b894e 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -3,6 +3,22 @@ use super::*; impl App { + pub(super) fn disable_ambient_pet_before_shutdown(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.chat_widget.disable_ambient_pet_for_session(); + if let Err(clear_err) = tui.clear_ambient_pet_image() { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear ambient pet image before shutdown feedback" + ); + } + } + } + Ok(()) + } + pub(super) fn handle_ambient_pet_image_render_error( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e76b4c0b96..2176e7762e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4130,6 +4130,15 @@ impl ChatComposer { } } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.set_input_enabled(/*enabled*/ false, Some("Shutting down...".to_string())); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = FooterMode::ComposerEmpty; + self.footer.hint_override = Some(Vec::new()); + self.footer.plan_mode_nudge_visible = false; + self.footer.flash = None; + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -4627,36 +4636,38 @@ impl ChatComposer { let mut state = self.draft.textarea_state.borrow_mut(); let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; - if let Some(mask_char) = mask_char { - self.draft - .textarea - .render_ref_masked(textarea_rect, buf, &mut state, mask_char); - } else { - let highlight_ranges = self.history_search_highlight_ranges(); - if highlight_ranges.is_empty() { - StatefulWidgetRef::render_ref( - &(&self.draft.textarea), - textarea_rect, - buf, - &mut state, - ); + if self.draft.input_enabled { + if let Some(mask_char) = mask_char { + self.draft + .textarea + .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { - let highlight_style = - Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); - let highlights = highlight_ranges - .into_iter() - .map(|range| (range, highlight_style)) - .collect::>(); - self.draft.textarea.render_ref_styled_with_highlights( - textarea_rect, - buf, - &mut state, - Style::default(), - &highlights, - ); + let highlight_ranges = self.history_search_highlight_ranges(); + if highlight_ranges.is_empty() { + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); + } else { + let highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + let highlights = highlight_ranges + .into_iter() + .map(|range| (range, highlight_style)) + .collect::>(); + self.draft.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + Style::default(), + &highlights, + ); + } } } - if textarea_is_empty { + if !self.draft.input_enabled || textarea_is_empty { let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { @@ -10684,4 +10695,41 @@ mod tests { }; assert_eq!(composer.cursor_pos(area), None); } + + #[test] + fn shutdown_in_progress_disables_input_and_uses_hint_without_footer() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + composer.show_shutdown_in_progress(); + + assert!(!composer.input_enabled()); + assert_eq!(composer.current_text(), "hello"); + assert_eq!(composer.custom_footer_height(), Some(0)); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + + let mut terminal = Terminal::new(TestBackend::new(40, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("shutdown_in_progress", terminal.backend()); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 165169c456..3f93c13898 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -789,6 +789,12 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.view_stack.clear(); + self.composer.show_shutdown_in_progress(); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { if let Some(text) = self.composer.clear_for_ctrl_c() { if let Some(thread_id) = self.thread_id { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap new file mode 100644 index 0000000000..15703f0ade --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Shutting down... " +" " +" " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a37371bf73..8c84748dde 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1277,6 +1277,10 @@ impl ChatWidget { .send(AppEvent::Exit(ExitMode::ShutdownFirst)); } + pub(crate) fn show_shutdown_in_progress(&mut self) { + self.bottom_pane.show_shutdown_in_progress(); + } + fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } From 9286ff2805c15d5ce031b016056fc6c5d0d75c4f Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 10:53:49 -0700 Subject: [PATCH 064/114] Fix remote turn diff display roots (#23261) ## Why `TurnDiffTracker` computes a display root so turn diffs can be rendered repo-relative. For remote exec-server turns, the selected turn `cwd` may exist only inside the selected environment, but `run_turn` was discovering the git root through the local host filesystem. When that lookup failed, nested remote-session diffs fell back to the nested `cwd` and showed `/tmp/...`-prefixed paths instead of repo-relative paths. ## What changed - Resolve the diff display root from the primary selected turn environment when one exists, using that environment's filesystem and `cwd`. - Add `codex_git_utils::get_git_repo_root_with_fs(...)` so git-root discovery can run against an `ExecutorFileSystem`, including remote environments. - Reuse that helper from `resolve_root_git_project_for_trust(...)` and add coverage for `.git` gitdir-pointer detection. ## Validation - Devbox Bazel: `//codex-rs/core:core-unit-tests --test_filter=get_git_repo_root_with_fs_detects_gitdir_pointer` - Devbox Docker-backed remote-env repro: `//codex-rs/core:core-all-test --test_filter=apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nested` --- codex-rs/core/src/git_info_tests.rs | 15 +++++++++++++++ codex-rs/core/src/session/turn.rs | 14 ++++++++++++-- codex-rs/git-utils/src/info.rs | 24 +++++++++++++++++++----- codex-rs/git-utils/src/lib.rs | 1 + 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index af62816f72..946779d06b 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -2,6 +2,7 @@ use codex_exec_server::LOCAL_FS; use codex_git_utils::GitInfo; use codex_git_utils::GitSha; use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root_with_fs; use codex_git_utils::get_has_changes; use codex_git_utils::git_diff_to_remote; use codex_git_utils::recent_commits; @@ -526,6 +527,20 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() { ); } +#[tokio::test] +async fn get_git_repo_root_with_fs_detects_gitdir_pointer() { + let tmp = TempDir::new().expect("tempdir"); + let proj = tmp.path().join("proj"); + let nested = proj.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap(); + + assert_eq!( + get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await, + Some(proj.abs()) + ); +} + #[tokio::test] async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index aee4bd360f..96569d0bfa 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -71,6 +71,7 @@ use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_features::Feature; use codex_git_utils::get_git_repo_root; +use codex_git_utils::get_git_repo_root_with_fs; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -370,8 +371,17 @@ pub(crate) async fn run_turn( // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. #[allow(deprecated)] - let display_root = get_git_repo_root(turn_context.cwd.as_path()) - .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()); + let display_root = match turn_context.environments.primary() { + Some(turn_environment) => get_git_repo_root_with_fs( + turn_environment.environment.get_filesystem().as_ref(), + &turn_environment.cwd, + ) + .await + .unwrap_or_else(|| turn_environment.cwd.clone()) + .into_path_buf(), + None => get_git_repo_root(turn_context.cwd.as_path()) + .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()), + }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( display_root, ))); diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 722d556b37..a188d9a876 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -38,6 +38,23 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option { find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root) } +/// Return the repository root for `cwd` using the provided filesystem. +/// +/// This mirrors [`get_git_repo_root`] for local paths, but works when `cwd` +/// only exists inside a selected remote environment. +pub async fn get_git_repo_root_with_fs( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, +) -> Option { + let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => cwd.clone(), + _ => cwd.parent()?, + }; + find_ancestor_git_entry_with_fs(fs, &base) + .await + .map(|(repo_root, _)| repo_root) +} + /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; @@ -729,11 +746,8 @@ pub async fn resolve_root_git_project_for_trust( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, ) -> Option { - let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { - Ok(metadata) if metadata.is_directory => cwd.clone(), - _ => cwd.parent()?, - }; - let (repo_root, dot_git) = find_ancestor_git_entry_with_fs(fs, &base).await?; + let repo_root = get_git_repo_root_with_fs(fs, cwd).await?; + let dot_git = repo_root.join(".git"); if fs .get_metadata(&dot_git, /*sandbox*/ None) .await diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index bcd1a8b5c9..986a7434d2 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -31,6 +31,7 @@ pub use info::default_branch_name; pub use info::get_git_remote_urls; pub use info::get_git_remote_urls_assume_git_repo; pub use info::get_git_repo_root; +pub use info::get_git_repo_root_with_fs; pub use info::get_has_changes; pub use info::get_head_commit_hash; pub use info::git_diff_to_remote; From b1c13b6fe535aeda00ed95b52c80f02e7d6ec745 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 11:00:03 -0700 Subject: [PATCH 065/114] Simplify legacy Windows sandbox ACL persistence (#22569) ## Why The legacy Windows sandbox still carried a `persist_aces` mode switch, even though the only path that meaningfully applies filesystem ACEs today is `workspace-write`, which already uses the persistent behavior. Legacy read-only sessions rely on the read-only capability SID rather than per-command filesystem ACE mutation, so the temporary cleanup branch had become conceptual overhead without a corresponding behavioral need. Removing that split makes the ACL lifecycle match the current sandbox model more directly and trims the guard/revocation plumbing from the legacy launcher paths. ## What changed - Removed the `persist_aces` parameter from legacy ACL preparation. - Made legacy deny-read handling always use the persistent reconciliation path. - Dropped guard tracking and post-exit ACE revocation from both capture and unified-exec legacy flows. - Kept workspace `.codex` / `.agents` protection tied directly to `WorkspaceWrite` instead of an intermediate persistence flag. ## Verification - `cargo fmt -p codex-windows-sandbox` - `git diff --check` - `cargo test -p codex-windows-sandbox` - 85 passed, 2 ignored, 2 (unrelated) failed locally. --- codex-rs/windows-sandbox-rs/src/lib.rs | 25 +------ codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 74 +++++-------------- .../src/unified_exec/backends/legacy.rs | 25 +------ 3 files changed, 21 insertions(+), 103 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index db958c9975..da2a2d3634 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -279,7 +279,6 @@ pub use stub::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] mod windows_impl { - use super::acl::revoke_ace; use super::logging::log_failure; use super::logging::log_success; use super::policy::SandboxPolicy; @@ -292,7 +291,6 @@ mod windows_impl { use super::spawn_prep::prepare_legacy_session_security; use super::spawn_prep::prepare_legacy_spawn_context; use super::spawn_prep::root_capability_sids; - use super::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -424,8 +422,7 @@ mod windows_impl { ); let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(is_workspace_write); - let persist_aces = is_workspace_write; - let guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( &policy, sandbox_policy_cwd, codex_home, @@ -438,7 +435,6 @@ mod windows_impl { readonly_sid_str: security.readonly_sid_str.as_deref(), write_root_sids: &security.write_root_sids, }, - persist_aces, )?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); @@ -463,13 +459,6 @@ mod windows_impl { CloseHandle(out_w); CloseHandle(err_r); CloseHandle(err_w); - if !persist_aces { - for (p, sid_str) in &guards { - if let Ok(sid) = LocalSid::from_string(sid_str) { - revoke_ace(p, sid.as_ptr()); - } - } - } CloseHandle(security.h_token); } return Err(err); @@ -570,15 +559,6 @@ mod windows_impl { log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - if !persist_aces { - unsafe { - for (p, sid_str) in guards { - if let Ok(sid) = LocalSid::from_string(&sid_str) { - revoke_ace(&p, sid.as_ptr()); - } - } - } - } Ok(CaptureResult { exit_code, stdout, @@ -609,7 +589,7 @@ mod windows_impl { codex_home, ); let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; - let _guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( sandbox_policy, sandbox_policy_cwd, codex_home, @@ -622,7 +602,6 @@ mod windows_impl { readonly_sid_str: None, write_root_sids: &write_root_sids, }, - /*persist_aces*/ true, )?; Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index 331df4f438..b27fa822fa 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -8,7 +8,6 @@ use crate::cap::workspace_write_cap_sid_for_root; use crate::cap::workspace_write_root_contains_path; use crate::cap::workspace_write_root_overlaps_path; use crate::cap::workspace_write_root_specificity; -use crate::deny_read_acl::apply_deny_read_acls; use crate::deny_read_state::sync_persistent_deny_read_acls; use crate::env::apply_no_network_to_env; use crate::env::ensure_non_interactive_pager; @@ -283,11 +282,9 @@ pub(crate) fn apply_legacy_session_acl_rules( additional_deny_read_paths: &[PathBuf], additional_deny_write_paths: &[PathBuf], acl_sids: LegacyAclSids<'_>, - persist_aces: bool, -) -> Result> { +) -> Result<()> { let AllowDenyPaths { allow, mut deny } = compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map); - let mut guards: Vec<(PathBuf, String)> = Vec::new(); unsafe { for path in additional_deny_write_paths { // Explicit carveouts must exist before the command starts so the @@ -300,31 +297,19 @@ pub(crate) fn apply_legacy_session_acl_rules( } if let Some(readonly_sid) = acl_sids.readonly_sid { for p in &allow { - if matches!(add_allow_ace(p, readonly_sid.as_ptr()), Ok(true)) - && !persist_aces - && let Some(readonly_sid_str) = acl_sids.readonly_sid_str - { - guards.push((p.clone(), readonly_sid_str.to_string())); - } + let _ = add_allow_ace(p, readonly_sid.as_ptr()); } } else { for p in &allow { let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { continue; }; - if matches!(add_allow_ace(p, root_sid.sid.as_ptr()), Ok(true)) && !persist_aces { - guards.push((p.clone(), root_sid.sid_str.clone())); - } + let _ = add_allow_ace(p, root_sid.sid.as_ptr()); } } for p in &deny { for root_sid in deny_root_capabilities_for_path(p, acl_sids.write_root_sids) { - if let Ok(added) = add_deny_write_ace(p, root_sid.sid.as_ptr()) - && added - && !persist_aces - { - guards.push((p.clone(), root_sid.sid_str.clone())); - } + let _ = add_deny_write_ace(p, root_sid.sid.as_ptr()); } } if !additional_deny_read_paths.is_empty() { @@ -332,42 +317,20 @@ pub(crate) fn apply_legacy_session_acl_rules( let Some(readonly_sid_str) = acl_sids.readonly_sid_str else { anyhow::bail!("readonly capability SID string missing"); }; - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - readonly_sid_str, - additional_deny_read_paths, - readonly_sid.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, readonly_sid.as_ptr())? - }; - if !persist_aces { - guards.extend( - applied_deny_read_paths - .into_iter() - .map(|path| (path, readonly_sid_str.to_string())), - ); - } + sync_persistent_deny_read_acls( + codex_home, + readonly_sid_str, + additional_deny_read_paths, + readonly_sid.as_ptr(), + )?; } else { for root_sid in acl_sids.write_root_sids { - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - &root_sid.sid_str, - additional_deny_read_paths, - root_sid.sid.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, root_sid.sid.as_ptr())? - }; - if !persist_aces { - guards.extend( - applied_deny_read_paths - .into_iter() - .map(|path| (path, root_sid.sid_str.clone())), - ); - } + sync_persistent_deny_read_acls( + codex_home, + &root_sid.sid_str, + additional_deny_read_paths, + root_sid.sid.as_ptr(), + )?; } } } @@ -377,8 +340,7 @@ pub(crate) fn apply_legacy_session_acl_rules( if let Some(readonly_sid) = acl_sids.readonly_sid { allow_null_device(readonly_sid.as_ptr()); } - if persist_aces - && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) && let Some(workspace_sid) = matching_root_capability(current_dir, acl_sids.write_root_sids) { @@ -389,7 +351,7 @@ pub(crate) fn apply_legacy_session_acl_rules( } } } - Ok(guards) + Ok(()) } #[allow(clippy::too_many_arguments)] diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 2f76147ce9..2cb12bfd71 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -1,6 +1,5 @@ use super::windows_common::finish_driver_spawn; use super::windows_common::normalize_windows_tty_input; -use crate::acl::revoke_ace; use crate::conpty::ConptyInstance; use crate::conpty::spawn_conpty_process_as_user; use crate::desktop::LaunchDesktop; @@ -16,7 +15,6 @@ use crate::spawn_prep::apply_legacy_session_acl_rules; use crate::spawn_prep::legacy_session_capability_roots; use crate::spawn_prep::prepare_legacy_session_security; use crate::spawn_prep::prepare_legacy_spawn_context; -use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; @@ -24,7 +22,6 @@ use codex_utils_pty::SpawnedProcess; use codex_utils_pty::TerminalSize; use std::collections::HashMap; use std::path::Path; -use std::path::PathBuf; use std::ptr; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -206,7 +203,6 @@ fn finalize_exit( process_handle: Arc>>, thread_handle: HANDLE, output_join: std::thread::JoinHandle<()>, - guards: Vec<(PathBuf, String)>, logs_base_dir: Option<&Path>, command: Vec, ) { @@ -242,14 +238,6 @@ fn finalize_exit( } else { log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - - unsafe { - for (path, cap_sid) in guards { - if let Ok(sid) = LocalSid::from_string(&cap_sid) { - revoke_ace(&path, sid.as_ptr()); - } - } - } } fn resize_conpty_handle(hpc: &Arc>>, size: TerminalSize) -> Result<()> { @@ -325,8 +313,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(common.is_workspace_write); - let persist_aces = common.is_workspace_write; - let guards = apply_legacy_session_acl_rules( + apply_legacy_session_acl_rules( &common.policy, sandbox_policy_cwd, codex_home, @@ -339,7 +326,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( readonly_sid_str: security.readonly_sid_str.as_deref(), write_root_sids: &security.write_root_sids, }, - persist_aces, )?; let (writer_tx, writer_rx) = mpsc::channel::>(128); @@ -375,13 +361,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( Ok(handles) => handles, Err(err) => { unsafe { - if !persist_aces { - for (path, cap_sid) in &guards { - if let Ok(sid) = LocalSid::from_string(cap_sid) { - revoke_ace(path, sid.as_ptr()); - } - } - } CloseHandle(security.h_token); } return Err(err); @@ -392,7 +371,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess))); let wait_handle = Arc::clone(&process_handle); let command_for_wait = command.clone(); - let guards_for_wait = if persist_aces { Vec::new() } else { guards }; let hpc_for_wait = hpc_handle.clone(); std::thread::spawn(move || { let _desktop = desktop; @@ -423,7 +401,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( wait_handle, pi.hThread, output_join, - guards_for_wait, common.logs_base_dir.as_deref(), command_for_wait, ); From 8c14b08dd1e4de6a3e9e73de5c735433ff5c55a3 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 11:10:37 -0700 Subject: [PATCH 066/114] Upload rust full CI JUnit reports (#23273) ## Why `rust-ci-full` failures currently leave downstream investigation reconstructing basic test facts from raw logs. `cargo nextest` can emit standard JUnit XML for each lane, which gives us a small structured artifact for post-run failure analysis without changing the test execution model. ## What changed - enable nextest JUnit output in `codex-rs/.config/nextest.toml` - upload the lane-scoped JUnit XML artifact from each `rust-ci-full` test lane ## Verification - `rust-ci-full` run `26018931531` on head `52d77c60e79b36859d944ef28a36b014055c5c48` produced JUnit artifacts for macOS, Linux x64 remote, Windows x64, and Windows ARM64 test lanes - `rust-ci-full` run `26021241006` on the same head produced the missing Linux ARM JUnit artifact after the first run lost that runner before export - downloaded all five lane JUnit artifacts and verified each contains non-empty test counters and failure data --- .github/workflows/rust-ci-full.yml | 8 ++++++++ codex-rs/.config/nextest.toml | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index de37faa4ca..697d4b9c37 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -688,6 +688,14 @@ jobs: RUST_MIN_STACK: "8388608" # 8 MiB NEXTEST_STATUS_LEVEL: leak + - name: Upload nextest JUnit report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-junit-rust-ci-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }} + path: codex-rs/target/nextest/default/junit.xml + if-no-files-found: warn + - name: Upload Cargo timings (nextest) if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 86d00e4637..cc86ee0874 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -2,6 +2,9 @@ # Do not increase, fix your test instead slow-timeout = { period = "15s", terminate-after = 2 } +[profile.default.junit] +path = "junit.xml" + [test-groups.app_server_protocol_codegen] max-threads = 1 From d32cb2c6aca2626d1b1d05c4537a5b6c2eec20f2 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 18 May 2026 11:22:42 -0700 Subject: [PATCH 067/114] fix: harden plugin creator sharing validation (#22893) # Summary Before this change, the sample plugin creator could emit placeholder-heavy manifests that fail workspace sharing, and it chose a repo-local marketplace implicitly whenever it ran from inside a git checkout. This PR makes generated plugins share-ready by default. It switches creation to the personal marketplace unless the caller explicitly opts into repo-local paths, adds a validator that mirrors the workspace plugin ingestion contract, and updates the skill prompt and docs to describe the real flow. The goal is to stop malformed generated plugins before they reach sharing and to make the default placement match the personal marketplace behavior users expect. ## Changes - Generate share-safe plugin manifests instead of `[TODO: ...]` placeholder payloads. - Default plugin and marketplace creation to `~/plugins` and `~/.agents/plugins/marketplace.json`. - Keep repo-local marketplace creation available through explicit `--path` and `--marketplace-path` arguments. - Add `validate_plugin.py` to check manifests, companion files, skill frontmatter, skill agent YAML, asset paths, and backend-shaped contracts before sharing. - Refresh the plugin creator skill text, reference docs, and default prompt to describe validation and the personal default. ## Design decisions - The validator tracks the workspace ingestion schema directly, including the required `defaultPrompt` alias handling and skill `agents/openai.yaml` checks. - The validator keeps one intentional extra preflight rule: leftover `[TODO: ...]` placeholders are rejected before sharing even when a single placeholder would not independently violate backend type validation. - Repo-local creation stays possible, but it is now explicit instead of cwd-sensitive. ## Testing Tests: targeted Python syntax checks, plugin skill validation, staged diff whitespace validation, 15 generated plugin smoke runs, backend manifest-schema acceptance for all 15 generated bundles, and a git-repo cwd regression proving the creator still writes to the personal marketplace by default. --- .../assets/samples/plugin-creator/SKILL.md | 49 +- .../samples/plugin-creator/agents/openai.yaml | 2 +- .../references/plugin-json-spec.md | 24 +- .../scripts/create_basic_plugin.py | 77 +-- .../plugin-creator/scripts/validate_plugin.py | 586 ++++++++++++++++++ 5 files changed, 673 insertions(+), 65 deletions(-) create mode 100644 codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 4a90165c07..dde5779e6c 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update personal or repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata. +description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata. --- # Plugin Creator @@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `. python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ``` -2. Open `/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders. +2. Edit `/.codex-plugin/plugin.json` when the request gives specific metadata. + The scaffold starts with valid defaults and must not contain `[TODO: ...]` placeholders. 3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering: @@ -26,14 +27,12 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py /plugins \ + --marketplace-path /.agents/plugins/marketplace.json \ --with-marketplace ``` @@ -48,17 +47,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ `` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). +5. Before handing back a generated plugin, run: + +```bash +python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +``` + ## What this skill creates - Default marketplace-backed scaffolds are personal: `~/plugins//` plus `~/.agents/plugins/marketplace.json`. -- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said - personal vs team, ask which marketplace to update before generating a marketplace entry. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. -- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. -- Creates or updates the selected marketplace when `--with-marketplace` is set. - - If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry. +- Fills the manifest with the validated schema shape that the ingestion path accepts. +- Creates or updates `~/.agents/plugins/marketplace.json` when `--with-marketplace` is set. + - If the marketplace file does not exist yet, seed a personal marketplace root before adding the first plugin entry. - `` is normalized using skill-creator naming rules: - `My Plugin` → `my-plugin` - `My--Plugin` → `my-plugin` @@ -74,8 +77,10 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ ## Marketplace workflow -- Personal plugins use `~/.agents/plugins/marketplace.json`. -- Repo/team plugins use `/.agents/plugins/marketplace.json`. +- Personal creation defaults to `~/.agents/plugins/marketplace.json`. +- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only + when the user specifically requests it. +- In either location, the generated source path remains `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. - `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. @@ -113,15 +118,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ ``` - Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name. -- If the selected marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry. +- If the target marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry. - For a brand-new marketplace file, the root object should look like: ```json { - "name": "[TODO: marketplace-name]", + "name": "personal", "interface": { - "displayName": "[TODO: Marketplace Display Name]" + "displayName": "Personal" }, "plugins": [ { @@ -144,7 +149,9 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ - Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name. - Do not remove required structure; keep `.codex-plugin/plugin.json` present. -- Keep manifest values as placeholders until a human or follow-up step explicitly fills them. +- Do not leave `[TODO: ...]` placeholders in plugin manifests. +- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created. +- Omit unsupported plugin manifest fields that validation rejects, including `hooks`. - If creating files inside an existing plugin path, use `--force` only when overwrite is intentional. - Preserve any existing marketplace `interface.displayName`. - When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults. @@ -176,3 +183,9 @@ After editing `SKILL.md`, run: ```bash python3 /scripts/quick_validate.py .agents/skills/plugin-creator ``` + +Before handing back a generated plugin, run: + +```bash +python3 .agents/skills/plugin-creator/scripts/validate_plugin.py +``` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml index 979b49859f..19a9a6fc5b 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml +++ b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Plugin Creator" short_description: "Scaffold plugins and marketplace entries" - default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry." + default_prompt: "Use $plugin-creator to scaffold a valid plugin in the personal marketplace, then validate it before handing it back." icon_small: "./assets/plugin-creator-small.svg" icon_large: "./assets/plugin-creator.png" diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index ee66dfe4f6..0fd444e64b 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -97,7 +97,8 @@ # Marketplace JSON sample spec -`marketplace.json` depends on where the plugin should live: +`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the +personal marketplace unless the caller explicitly requests a repo-local destination: - Personal plugin: `~/.agents/plugins/marketplace.json` - Repo/team plugin: `/.agents/plugins/marketplace.json` @@ -166,8 +167,25 @@ - Append new entries unless the user explicitly requests reordering. - Replace an existing entry for the same plugin only when overwrite is intentional. - Default new plugin creation to the personal marketplace. -- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said - personal or team, ask which marketplace to update before creating the entry. +- Use a repo/team marketplace only when the user specifically requests that destination. - Choose marketplace location to match the selected destination: - Personal plugin: `~/.agents/plugins/marketplace.json` - Repo/team plugin: `/.agents/plugins/marketplace.json` + +### Plugin validation notes + +- The validator mirrors the workspace plugin ingestion schema so generated plugins follow the same + manifest contract from the start. +- Plugin manifests must include real values for `name`, `version`, `description`, + `author.name`, and the required `interface` fields. +- `version` must use strict semver. +- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when + present. +- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when + present. +- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json` + actually exist. +- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of + generated manifests. +- Run `scripts/validate_plugin.py ` before handing back a generated plugin. It adds one + intentional preflight check that rejects leftover `[TODO: ...]` placeholders. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index dcb24562e8..fb882ce99a 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -16,7 +16,8 @@ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.js DEFAULT_INSTALL_POLICY = "AVAILABLE" DEFAULT_AUTH_POLICY = "ON_INSTALL" DEFAULT_CATEGORY = "Productivity" -DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]" +DEFAULT_MARKETPLACE_NAME = "personal" +DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal" VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} @@ -40,49 +41,35 @@ def validate_plugin_name(plugin_name: str) -> None: ) -def build_plugin_json(plugin_name: str) -> dict: - return { +def display_name_from_plugin_name(plugin_name: str) -> str: + return " ".join(part.capitalize() for part in plugin_name.split("-")) + + +def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]: + display_name = display_name_from_plugin_name(plugin_name) + payload: dict[str, Any] = { "name": plugin_name, - "version": "[TODO: 1.2.0]", - "description": "[TODO: Brief plugin description]", + "version": "0.1.0", + "description": f"{display_name} plugin", "author": { - "name": "[TODO: Author Name]", - "email": "[TODO: author@example.com]", - "url": "[TODO: https://github.com/author]", + "name": "Local developer", }, - "homepage": "[TODO: https://docs.example.com/plugin]", - "repository": "[TODO: https://github.com/author/plugin]", - "license": "[TODO: MIT]", - "keywords": ["[TODO: keyword1]", "[TODO: keyword2]"], - "skills": "[TODO: ./skills/]", - "hooks": "[TODO: ./hooks.json]", - "mcpServers": "[TODO: ./.mcp.json]", - "apps": "[TODO: ./.app.json]", + "skills": "./skills/", "interface": { - "displayName": "[TODO: Plugin Display Name]", - "shortDescription": "[TODO: Short description for subtitle]", - "longDescription": "[TODO: Long description for details page]", - "developerName": "[TODO: OpenAI]", - "category": "[TODO: Productivity]", - "capabilities": ["[TODO: Interactive]", "[TODO: Write]"], - "websiteURL": "[TODO: https://openai.com/]", - "privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]", - "termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]", - "defaultPrompt": [ - "[TODO: Summarize my inbox and draft replies for me.]", - "[TODO: Find open bugs and turn them into tickets.]", - "[TODO: Review today's meetings and flag gaps.]", - ], - "brandColor": "[TODO: #3B82F6]", - "composerIcon": "[TODO: ./assets/icon.png]", - "logo": "[TODO: ./assets/logo.png]", - "screenshots": [ - "[TODO: ./assets/screenshot1.png]", - "[TODO: ./assets/screenshot2.png]", - "[TODO: ./assets/screenshot3.png]", - ], + "displayName": display_name, + "shortDescription": f"Use {display_name} in Codex.", + "longDescription": f"{display_name} adds a local Codex plugin scaffold.", + "developerName": "Local developer", + "category": DEFAULT_CATEGORY, + "capabilities": [], + "defaultPrompt": f"Help me use {display_name}.", }, } + if with_mcp: + payload["mcpServers"] = "./.mcp.json" + if with_apps: + payload["apps"] = "./.app.json" + return payload def build_marketplace_entry( @@ -112,7 +99,7 @@ def load_json(path: Path) -> dict[str, Any]: def build_default_marketplace() -> dict[str, Any]: return { - "name": "[TODO: marketplace-name]", + "name": DEFAULT_MARKETPLACE_NAME, "interface": { "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, }, @@ -185,7 +172,7 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Create a plugin skeleton with placeholder plugin.json." + description="Create a plugin skeleton with a validation-ready plugin.json." ) parser.add_argument("plugin_name") parser.add_argument( @@ -193,7 +180,7 @@ def parse_args() -> argparse.Namespace: default=str(DEFAULT_PLUGIN_PARENT), help=( "Parent directory for plugin creation (defaults to /plugins). " - "Use /plugins when creating a repo/team plugin." + "Pass an explicit repo path only when a repo/team plugin is intended." ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") @@ -216,7 +203,7 @@ def parse_args() -> argparse.Namespace: default=str(DEFAULT_MARKETPLACE_PATH), help=( "Path to marketplace.json (defaults to /.agents/plugins/marketplace.json). " - "Use /.agents/plugins/marketplace.json for a repo/team plugin." + "Pass a repo-rooted marketplace path only when a repo/team plugin is intended." ), ) parser.add_argument( @@ -252,7 +239,11 @@ def main() -> None: plugin_root.mkdir(parents=True, exist_ok=True) plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json" - write_json(plugin_json_path, build_plugin_json(plugin_name), args.force) + write_json( + plugin_json_path, + build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps), + args.force, + ) optional_directories = { "skills": args.with_skills, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py new file mode 100644 index 0000000000..64518064e5 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 +"""Validate a generated plugin against the plugin ingestion contract.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import urlparse + +import yaml + + +TODO_MARKER = "[TODO:" +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\." + r"(0|[1-9]\d*)\." + r"(0|[1-9]\d*)" + r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\." + r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate a local Codex plugin.") + parser.add_argument("plugin_path", help="Path to the plugin root directory") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + plugin_root = Path(args.plugin_path).expanduser().resolve() + errors = validate_plugin(plugin_root) + if errors: + print("Plugin validation failed:") + for error in errors: + print(f"- {error}") + raise SystemExit(1) + print(f"Plugin validation passed: {plugin_root}") + + +def validate_plugin(plugin_root: Path) -> list[str]: + errors: list[str] = [] + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + manifest = load_json_object(manifest_path, errors) + if manifest is None: + return errors + + reject_todo_markers(manifest, "$", errors) + validate_manifest_shape(plugin_root, manifest, errors) + return errors + + +def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None: + if not path.is_file(): + errors.append("missing `.codex-plugin/plugin.json`") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError: + errors.append("unable to read `.codex-plugin/plugin.json`") + return None + except json.JSONDecodeError: + errors.append("`.codex-plugin/plugin.json` must be valid JSON") + return None + if not isinstance(payload, dict): + errors.append("`.codex-plugin/plugin.json` must contain a JSON object") + return None + return payload + + +def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None: + if isinstance(value, str): + if TODO_MARKER in value: + errors.append(f"{path} still contains a `[TODO: ...]` placeholder") + return + if isinstance(value, list): + for index, item in enumerate(value): + reject_todo_markers(item, f"{path}[{index}]", errors) + return + if isinstance(value, dict): + for key, item in value.items(): + reject_todo_markers(item, f"{path}.{key}", errors) + + +def validate_manifest_shape( + plugin_root: Path, + manifest: dict[str, Any], + errors: list[str], +) -> None: + allowed_keys = { + "id", + "name", + "version", + "description", + "skills", + "apps", + "mcpServers", + "interface", + "author", + "homepage", + "repository", + "license", + "keywords", + } + for key in sorted(set(manifest) - allowed_keys): + errors.append(f"plugin.json field `{key}` is not accepted by plugin validation") + + validate_optional_non_empty_string(manifest, "id", errors) + require_non_empty_string(manifest, "name", errors) + version = require_non_empty_string(manifest, "version", errors) + if version is not None and SEMVER_RE.fullmatch(version) is None: + errors.append("plugin.json field `version` must be strict semver") + require_non_empty_string(manifest, "description", errors) + + author = require_object(manifest, "author", errors) + if author is not None: + reject_unknown_fields(author, {"name", "email", "url"}, "author", errors) + require_non_empty_string(author, "name", errors, prefix="author") + validate_optional_non_empty_string(author, "email", errors, prefix="author") + validate_optional_https_url(author, "url", errors, prefix="author") + + validate_optional_contract_path(manifest, "skills", "skills", errors) + validate_optional_contract_path(manifest, "apps", ".app.json", errors) + validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors) + + if manifest.get("apps") is not None: + validate_app_manifest( + plugin_root / ".app.json", + errors, + ) + if manifest.get("mcpServers") is not None: + validate_mcp_manifest( + plugin_root / ".mcp.json", + errors, + ) + validate_skill_manifests(plugin_root, errors) + + interface = require_object(manifest, "interface", errors) + if interface is None: + return + reject_unknown_fields( + interface, + { + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + "capabilities", + "websiteURL", + "privacyPolicyURL", + "termsOfServiceURL", + "brandColor", + "composerIcon", + "logo", + "screenshots", + "defaultPrompt", + "default_prompt", + }, + "interface", + errors, + ) + for field in ( + "displayName", + "shortDescription", + "longDescription", + "developerName", + "category", + ): + require_non_empty_string(interface, field, errors, prefix="interface") + if "defaultPrompt" not in interface and "default_prompt" not in interface: + errors.append( + "plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required" + ) + capabilities = interface.get("capabilities") + if not isinstance(capabilities, list) or not all( + isinstance(value, str) and value.strip() for value in capabilities + ): + errors.append("plugin.json field `interface.capabilities` must be an array of strings") + for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"): + validate_optional_https_url(interface, field, errors, prefix="interface") + brand_color = interface.get("brandColor") + if brand_color is not None and ( + not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None + ): + errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`") + for field in ("composerIcon", "logo"): + validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors) + screenshots = interface.get("screenshots", []) + if not isinstance(screenshots, list): + errors.append("plugin.json field `interface.screenshots` must be an array") + else: + for index, raw_path in enumerate(screenshots): + validate_asset_path( + plugin_root, + plugin_root, + raw_path, + f"interface.screenshots[{index}]", + errors, + ) + + +def require_object( + payload: dict[str, Any], + key: str, + errors: list[str], +) -> dict[str, Any] | None: + value = payload.get(key) + if not isinstance(value, dict): + errors.append(f"plugin.json field `{key}` must be an object") + return None + return value + + +def require_non_empty_string( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str | None = None, +) -> str | None: + value = payload.get(key) + field = f"{prefix}.{key}" if prefix is not None else key + if not isinstance(value, str) or not value.strip(): + errors.append(f"plugin.json field `{field}` must be a non-empty string") + return None + return value + + +def validate_optional_non_empty_string( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str | None = None, +) -> None: + value = payload.get(key) + if value is None: + return + field = f"{prefix}.{key}" if prefix is not None else key + if not isinstance(value, str) or not value.strip(): + errors.append(f"plugin.json field `{field}` must be a non-empty string") + + +def reject_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + prefix: str, + errors: list[str], +) -> None: + for key in sorted(set(payload) - allowed_keys): + errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation") + + +def validate_optional_https_url( + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str, +) -> None: + value = payload.get(key) + if value is None: + return + parsed = urlparse(value) if isinstance(value, str) else None + if parsed is None or parsed.scheme != "https" or not parsed.netloc: + errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL") + + +def validate_optional_contract_path( + payload: dict[str, Any], + key: str, + expected: str, + errors: list[str], +) -> None: + value = payload.get(key) + if value is None: + return + normalized = normalize_contract_path(value) if isinstance(value, str) else None + if normalized != expected: + errors.append(f"plugin.json field `{key}` must resolve to `{expected}`") + + +def normalize_contract_path(raw_path: str) -> str | None: + path = Path(raw_path) + if path.is_absolute(): + return None + normalized = path.as_posix().rstrip("/") + return normalized or None + + +def validate_app_manifest(path: Path, errors: list[str]) -> None: + payload = load_companion_json_object(path, "`.app.json`", errors) + if payload is None: + return + reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors) + apps = payload.get("apps") + if not isinstance(apps, dict): + errors.append("`.app.json` field `apps` must be an object") + return + for key, value in apps.items(): + if not isinstance(value, dict): + errors.append(f"`.app.json` app `{key}` must be an object") + continue + reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors) + app_id = value.get("id") + if not isinstance(app_id, str) or not app_id.strip(): + errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string") + + +def validate_mcp_manifest(path: Path, errors: list[str]) -> None: + payload = load_companion_json_object(path, "`.mcp.json`", errors) + if payload is None: + return + reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors) + servers = payload.get("mcpServers") + if not isinstance(servers, dict): + errors.append("`.mcp.json` field `mcpServers` must be an object") + return + for key, value in servers.items(): + if not isinstance(key, str) or not key.strip(): + errors.append("`.mcp.json` server names must be non-empty strings") + if not isinstance(value, dict): + errors.append(f"`.mcp.json` server `{key}` must be an object") + + +def load_companion_json_object( + path: Path, + label: str, + errors: list[str], +) -> dict[str, Any] | None: + if not path.is_file(): + errors.append(f"{label} is required when its plugin.json field is present") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + errors.append(f"{label} must contain valid JSON") + return None + if not isinstance(payload, dict): + errors.append(f"{label} must contain a JSON object") + return None + return payload + + +def reject_companion_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + prefix: str, + errors: list[str], +) -> None: + for key in sorted(set(payload) - allowed_keys): + errors.append(f"{prefix} field `{key}` is not accepted by plugin validation") + + +def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None: + skills_root = plugin_root / "skills" + if not skills_root.is_dir(): + return + for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name): + if skill_root.name.startswith(".") or not skill_root.is_dir(): + continue + validate_skill_manifest(skill_root, errors) + + +def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None: + skill_md_path = skill_root / "SKILL.md" + if not skill_md_path.is_file(): + errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`") + return + try: + contents = skill_md_path.read_text(encoding="utf-8") + except OSError: + errors.append(f"unable to read skill `{skill_root.name}`") + return + if not contents.startswith("---\n"): + errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter") + return + frontmatter_end = contents.find("\n---", 4) + if frontmatter_end == -1: + errors.append(f"skill `{skill_root.name}` frontmatter is not closed") + return + try: + frontmatter = yaml.safe_load(contents[4:frontmatter_end]) + except yaml.YAMLError: + errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML") + return + if not isinstance(frontmatter, dict): + errors.append(f"skill `{skill_root.name}` frontmatter must be an object") + return + skill_name = frontmatter.get("name") + if not isinstance(skill_name, str) or not skill_name.strip(): + errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty") + description = frontmatter.get("description") + if not isinstance(description, str) or not description.strip(): + errors.append( + f"skill `{skill_root.name}` frontmatter field `description` must be non-empty" + ) + disable_model_invocation = frontmatter.get("disable-model-invocation") + if disable_model_invocation is None: + disable_model_invocation = frontmatter.get("disable_model_invocation") + if disable_model_invocation not in (None, False): + errors.append( + f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false" + ) + agent_yaml_path = skill_root / "agents" / "openai.yaml" + if agent_yaml_path.is_file(): + validate_skill_agent_manifest( + plugin_root=skill_root.parent.parent, + skill_root=skill_root, + agent_yaml_path=agent_yaml_path, + errors=errors, + ) + + +def validate_skill_agent_manifest( + *, + plugin_root: Path, + skill_root: Path, + agent_yaml_path: Path, + errors: list[str], +) -> None: + try: + payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8")) + except OSError: + errors.append(f"unable to read skill `{skill_root.name}` agent YAML") + return + except yaml.YAMLError: + errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML") + return + if not isinstance(payload, dict): + errors.append(f"skill `{skill_root.name}` agent YAML must be an object") + return + + reject_skill_agent_unknown_fields( + payload, + {"interface", "policy", "dependencies"}, + skill_root, + errors, + ) + interface = payload.get("interface") + if not isinstance(interface, dict): + errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object") + return + reject_skill_agent_unknown_fields( + interface, + { + "display_name", + "short_description", + "icon_small", + "icon_large", + "brand_color", + "default_prompt", + }, + skill_root, + errors, + prefix="interface", + ) + for field in ("display_name", "short_description"): + value = interface.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append( + f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty" + ) + for field in ("icon_small", "icon_large"): + validate_optional_asset_path( + skill_root, + plugin_root, + interface, + field, + errors, + prefix=f"skill `{skill_root.name}` agent field `interface", + ) + brand_color = interface.get("brand_color") + if brand_color is not None and ( + not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None + ): + errors.append( + f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`" + ) + default_prompt = interface.get("default_prompt") + if default_prompt is not None and ( + not isinstance(default_prompt, str) or not default_prompt.strip() + ): + errors.append( + f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty" + ) + + policy = payload.get("policy") + if policy is not None: + if not isinstance(policy, dict): + errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object") + else: + reject_skill_agent_unknown_fields( + policy, + {"allow_implicit_invocation"}, + skill_root, + errors, + prefix="policy", + ) + allow_implicit_invocation = policy.get("allow_implicit_invocation") + if allow_implicit_invocation is not None and not isinstance( + allow_implicit_invocation, + bool, + ): + errors.append( + f"skill `{skill_root.name}` agent field " + "`policy.allow_implicit_invocation` must be a boolean" + ) + + dependencies = payload.get("dependencies") + if dependencies is not None: + if not isinstance(dependencies, dict): + errors.append( + f"skill `{skill_root.name}` agent field `dependencies` must be an object" + ) + else: + reject_skill_agent_unknown_fields( + dependencies, + {"tools"}, + skill_root, + errors, + prefix="dependencies", + ) + + +def reject_skill_agent_unknown_fields( + payload: dict[str, Any], + allowed_keys: set[str], + skill_root: Path, + errors: list[str], + *, + prefix: str | None = None, +) -> None: + for key in sorted(set(payload) - allowed_keys): + field = f"{prefix}.{key}" if prefix is not None else key + errors.append( + f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation" + ) + + +def validate_optional_asset_path( + base_dir: Path, + allowed_root: Path, + payload: dict[str, Any], + key: str, + errors: list[str], + *, + prefix: str = "interface", +) -> None: + raw_path = payload.get(key) + if raw_path is None: + return + validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors) + + +def validate_asset_path( + base_dir: Path, + allowed_root: Path, + raw_path: Any, + field: str, + errors: list[str], +) -> None: + label = field if field.startswith("skill `") else f"plugin.json field `{field}`" + if not isinstance(raw_path, str) or not raw_path.strip(): + errors.append(f"{label} must be a non-empty relative path") + return + candidate = PurePosixPath(raw_path.replace("\\", "/")) + if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts): + errors.append(f"{label} must stay inside the plugin archive") + return + resolved_path = (base_dir / candidate.as_posix()).resolve() + if not resolved_path.is_relative_to(allowed_root.resolve()): + errors.append(f"{label} must stay inside the plugin archive") + return + if not resolved_path.is_file(): + errors.append(f"{label} points to a missing file") + + +if __name__ == "__main__": + main() From 0d344aca9b0caee4e5a508ee10d8a72f4d416896 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:28:53 -0700 Subject: [PATCH 068/114] goal: pause continuation loops on usage limits and blockers (#23094) Addresses #22833, #22245, #23067 ## Why `/goal` can keep synthesizing turns even when the next turn cannot make meaningful progress. Hard usage exhaustion can replay failing turns, and repeated permission or external-resource blockers can keep burning tokens while waiting for user or system intervention. ## What changed - Add resumable `blocked` and `usageLimited` goal states. As with `paused`, goal continuation stops with these states. - Move to `usageLimited` after usage-limit failures. - Allow the built-in `update_goal` tool to set `blocked` only under explicit repeated-impasse guidance. Updated goal continuation prompt to specify that agent should use `blocked` only when it has made at least three attempts to get past an impasse. Most of the files touched by this PR are because of the small app server protocol update. ## Validation I manually reproduced a number of situations where an agent can run into a true impasse and verified that it properly enters `blocked` state. I then resumed and verified that it once again entered `blocked` state several turns later if the impasse still exists. I also manually reproduced the usage-limit condition by creating a simulated responses API endpoint that returns 429 errors with the appropriate error message. Verified that the goal runtime properly moves the goal into `usageLimited` state and TUI UI updates appropriately. Verified that `/goal resume` resumes (and immediately goes back into `ussageLImited` state if appropriate). ## Follow-up PRs Small changes will be needed to the GUI clients to properly handle the two new states. --- .../schema/json/ClientRequest.json | 2 + .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 2 + .../codex_app_server_protocol.v2.schemas.json | 2 + .../v2/ThreadGoalUpdatedNotification.json | 2 + .../schema/typescript/v2/ThreadGoalStatus.ts | 2 +- .../src/protocol/v2/thread.rs | 2 + codex-rs/app-server/README.md | 6 +- .../thread_goal_processor.rs | 4 + .../tests/suite/v2/thread_resume.rs | 86 +++++++++ codex-rs/core/src/goals.rs | 109 +++++++++++- codex-rs/core/src/session/tests.rs | 167 +++++++++++++++++- codex-rs/core/src/session/turn.rs | 36 +++- codex-rs/core/src/tools/handlers/goal.rs | 6 +- .../src/tools/handlers/goal/update_goal.rs | 16 +- codex-rs/core/src/tools/handlers/goal_spec.rs | 19 +- codex-rs/core/templates/goals/continuation.md | 10 +- codex-rs/otel/src/metrics/names.rs | 2 + codex-rs/protocol/src/protocol.rs | 2 + .../0033_thread_goal_stopped_statuses.sql | 48 +++++ codex-rs/state/src/model/thread_goal.rs | 6 + codex-rs/state/src/runtime/goals.rs | 150 +++++++++++++++- codex-rs/tui/src/app/thread_goal_actions.rs | 5 +- codex-rs/tui/src/bottom_pane/footer.rs | 4 + codex-rs/tui/src/chatwidget/goal_menu.rs | 10 +- codex-rs/tui/src/chatwidget/goal_status.rs | 2 + codex-rs/tui/src/chatwidget/interaction.rs | 22 +++ ..._chatwidget__tests__goal_menu_blocked.snap | 11 ++ ...idget__tests__goal_menu_usage_limited.snap | 11 ++ .../tui/src/chatwidget/tests/goal_menu.rs | 64 +++++++ .../src/chatwidget/tests/status_and_layout.rs | 60 +++++++ codex-rs/tui/src/goal_display.rs | 2 + 32 files changed, 837 insertions(+), 35 deletions(-) create mode 100644 codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9c2415678f..6678d9c0d1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3124,6 +3124,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6e8b5d8a04..5d4af3d936 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3257,6 +3257,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], 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 94abf054db..c4bb4a2ff4 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 @@ -15561,6 +15561,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], 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 601e9dd7fb..c176c2a4b8 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 @@ -13385,6 +13385,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json index 52a2e905a2..cbb04eaab2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -51,6 +51,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts index 7a4bf332fb..46ec7ddd69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; +export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index ffe2c353d1..53f942cf80 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -555,6 +555,8 @@ v2_enum_from_core! { pub enum ThreadGoalStatus from CoreThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d0f423d5c7..5fa61b3c17 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -496,7 +496,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back ### Example: Set and update a thread goal -Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. +Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted, `blocked` when progress is waiting on outside intervention, and `usageLimited` when usage availability stops further work. The system also sets `budgetLimited` when accounting crosses a configured token budget and `usageLimited` when a turn ends on a hard usage-limit error. ```json { "method": "thread/goal/set", "id": 27, "params": { @@ -529,12 +529,12 @@ Use `thread/goal/set` to create or update the current goal for a materialized th ```json { "method": "thread/goal/set", "id": 28, "params": { "threadId": "thr_123", - "status": "paused" + "status": "blocked" } } { "id": 28, "result": { "goal": { "threadId": "thr_123", "objective": "Keep improving the benchmark until p95 latency is under 120ms", - "status": "paused", + "status": "blocked", "tokenBudget": 200000, "tokensUsed": 10000, "timeUsedSeconds": 60, diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index a7c9a2c20c..0133114b84 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -454,6 +454,8 @@ fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadG match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -463,6 +465,8 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 852b86e562..c9a5ae1485 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -893,6 +893,92 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + for (wire_status, expected_status) in [ + ("blocked", ThreadGoalStatus::Blocked), + ("usageLimited", ThreadGoalStatus::UsageLimited), + ] { + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep polishing", + "status": wire_status, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, expected_status); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, expected_status); + } + + Ok(()) +} + #[tokio::test] async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 717afb65c2..0f3b8e36eb 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -15,12 +15,14 @@ use crate::tasks::RegularTask; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use anyhow::Context; use codex_features::Feature; +use codex_otel::GOAL_BLOCKED_METRIC; use codex_otel::GOAL_BUDGET_LIMITED_METRIC; use codex_otel::GOAL_COMPLETED_METRIC; use codex_otel::GOAL_CREATED_METRIC; use codex_otel::GOAL_DURATION_SECONDS_METRIC; use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; +use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -156,6 +158,9 @@ pub(crate) enum GoalRuntimeEvent<'a> { turn_context: Option<&'a TurnContext>, reason: TurnAbortReason, }, + UsageLimitReached { + turn_context: &'a TurnContext, + }, ExternalMutationStarting, ExternalSet { external_set: ExternalGoalSet, @@ -393,6 +398,11 @@ impl Session { .await; Ok(()) }), + GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { + self.usage_limit_active_thread_goal_for_turn(turn_context) + .await?; + Ok(()) + }), GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { if let Err(err) = self.account_thread_goal_before_external_mutation().await { tracing::warn!( @@ -537,6 +547,7 @@ impl Session { if replacing_goal { self.emit_goal_created_metric(); } + self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); let goal = protocol_goal_from_state(goal); *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; @@ -653,6 +664,7 @@ impl Session { let previous_status = previous_goal .as_ref() .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); + self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); let goal_id = goal.goal_id; @@ -681,7 +693,10 @@ impl Session { self.clear_stopped_thread_goal_runtime_state().await; } } - codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => { + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; } } @@ -741,6 +756,25 @@ impl Session { .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); } + fn emit_goal_resumed_metric_if_status_changed( + &self, + previous_status: Option, + goal_status: codex_state::ThreadGoalStatus, + ) { + if goal_status == codex_state::ThreadGoalStatus::Active + && matches!( + previous_status, + Some( + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + ) + ) + { + self.emit_goal_resumed_metric(); + } + } + fn emit_goal_terminal_metrics_if_status_changed( &self, previous_status: Option, @@ -751,6 +785,8 @@ impl Session { } let counter = match goal.status { + codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, + codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { @@ -1011,6 +1047,8 @@ impl Session { matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::Complete => true, }; { @@ -1200,6 +1238,59 @@ impl Session { Ok(()) } + async fn usage_limit_active_thread_goal_for_turn( + &self, + turn_context: &TurnContext, + ) -> anyhow::Result<()> { + if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { + return Ok(()); + } + + if !self.enabled(Feature::Goals) { + return Ok(()); + } + + let _continuation_guard = self + .goal_runtime + .continuation_lock + .acquire() + .await + .context("goal continuation semaphore closed")?; + let Some(state_db) = self.state_db_for_thread_goals().await? else { + return Ok(()); + }; + self.account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, + ) + .await?; + let previous_status = self + .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) + .await?; + let Some(goal) = state_db + .thread_goals() + .usage_limit_active_thread_goal(self.conversation_id) + .await? + else { + return Ok(()); + }; + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + let goal = protocol_goal_from_state(goal); + *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; + self.clear_active_goal_accounting(turn_context).await; + self.send_event( + turn_context, + EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.conversation_id, + turn_id: Some(turn_context.sub_id.clone()), + goal, + }), + ) + .await; + Ok(()) + } + async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { if !self.enabled(Feature::Goals) { return Ok(()); @@ -1239,6 +1330,8 @@ impl Session { self.emit_goal_resumed_metric(); } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::BudgetLimited | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; @@ -1594,6 +1687,8 @@ pub(crate) fn protocol_goal_status_from_state( match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } @@ -1605,6 +1700,8 @@ pub(crate) fn state_goal_status_from_protocol( match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -1683,7 +1780,7 @@ mod tests { } #[test] - fn continuation_prompt_only_tells_model_to_update_goal_when_complete() { + fn continuation_prompt_allows_complete_and_strict_blocked_updates() { let prompt = continuation_prompt(&ThreadGoal { thread_id: ThreadId::new(), objective: "finish the stack".to_string(), @@ -1700,9 +1797,11 @@ mod tests { assert!(prompt.contains("\nfinish the stack\n")); assert!(prompt.contains("Token budget: 10000")); assert!(prompt.contains("call update_goal with status \"complete\"")); - assert!(!prompt.contains( - "explain the blocker or next required input to the user and wait for new input" - )); + assert!(prompt.contains("status \"blocked\"")); + assert!(prompt.contains("at least three consecutive goal turns")); + assert!(prompt.contains("same blocking condition")); + assert!(prompt.contains("original/user-triggered turn")); + assert!(prompt.contains("truly at an impasse")); assert!(!prompt.contains("budgetLimited")); assert!(!prompt.contains("status \"paused\"")); } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4511df4583..d3f6917775 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8629,6 +8629,57 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> +{ + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + sess.set_thread_goal( + tc.as_ref(), + SetGoalRequest { + objective: Some("Keep improving the benchmark".to_string()), + status: None, + token_budget: Some(Some(50)), + }, + ) + .await?; + sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { + turn_context: tc.as_ref(), + token_usage: TokenUsage::default(), + }) + .await?; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + set_total_token_usage(&sess, post_goal_token_usage()).await; + + sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: tc.as_ref(), + }) + .await?; + + let state_db = goal_test_state_db(sess.as_ref()).await?; + let goal = state_db + .thread_goals() + .get_thread_goal(sess.conversation_id) + .await? + .expect("goal should remain persisted after usage limiting"); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!(70, goal.tokens_used); + + sess.abort_all_tasks(TurnAbortReason::Replaced).await; + sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await?; + assert!(sess.active_turn.lock().await.is_none()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; @@ -9550,7 +9601,121 @@ async fn update_goal_tool_rejects_pausing_goal() { }; assert_eq!( output, - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" + ); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Active); +} + +#[tokio::test] +async fn update_goal_tool_marks_goal_blocked() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "block-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "blocked", + }) + .to_string(), + }, + }) + .await + .expect("update_goal should mark the goal blocked"); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Blocked); +} + +#[tokio::test] +async fn update_goal_tool_rejects_usage_limited_goal() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + let response = update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "usage-limit-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "usageLimited", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = response else { + panic!("expected update_goal to reject usage-limiting a goal"); + }; + assert_eq!( + output, + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" ); let goal = session diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 96569d0bfa..ab316440e5 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -20,6 +20,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; +use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; @@ -162,7 +163,16 @@ pub(crate) async fn run_turn( let pre_sampling_compact = match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { Ok(pre_sampling_compact) => pre_sampling_compact, - Err(_) => { + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } error!("Failed to run pre-sampling compact"); return None; } @@ -517,7 +527,20 @@ pub(crate) async fn run_turn( .await { Ok(reset_client_session) => reset_client_session, - Err(_) => return None, + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!( + "failed to usage-limit active goal after usage-limit error: {err}" + ); + } + return None; + } }; if reset_client_session { client_session.reset_websocket_session(); @@ -673,6 +696,15 @@ pub(crate) async fn run_turn( } Err(e) => { info!("Turn error: {e:#}"); + if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index e50095e524..694eafca65 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -1,8 +1,8 @@ //! Built-in model tool handlers for persisted thread goals. //! -//! The public tool contract intentionally splits goal creation from completion: -//! `create_goal` starts an active objective, while `update_goal` can only mark -//! the existing goal complete. +//! The public tool contract intentionally splits goal creation from stopped +//! status updates: `create_goal` starts an active objective, while +//! `update_goal` can only mark the existing goal complete or blocked. use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index 8cf8ce546a..16883e0755 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -51,9 +51,12 @@ impl ToolExecutor for UpdateGoalHandler { }; let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if args.status != ThreadGoalStatus::Complete { + if !matches!( + args.status, + ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked + ) { return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" .to_string(), )); } @@ -68,13 +71,18 @@ impl ToolExecutor for UpdateGoalHandler { turn.as_ref(), SetGoalRequest { objective: None, - status: Some(ThreadGoalStatus::Complete), + status: Some(args.status), token_budget: None, }, ) .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output) + let completion_budget_report = if args.status == ThreadGoalStatus::Complete { + CompletionBudgetReport::Include + } else { + CompletionBudgetReport::Omit + }; + goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) } } diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs index a5ea0ad2f4..a3ae8703ce 100644 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ b/codex-rs/core/src/tools/handlers/goal_spec.rs @@ -63,9 +63,9 @@ pub fn create_update_goal_tool() -> ToolSpec { let properties = BTreeMap::from([( "status".to_string(), JsonSchema::string_enum( - vec![json!("complete")], + vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -74,10 +74,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, @@ -96,7 +100,7 @@ mod tests { use super::*; #[test] - fn update_goal_tool_only_exposes_complete_status() { + fn update_goal_tool_exposes_complete_and_blocked_statuses() { let ToolSpec::Function(tool) = create_update_goal_tool() else { panic!("update_goal should be a function tool"); }; @@ -107,6 +111,9 @@ mod tests { .and_then(|properties| properties.get("status")) .expect("status property should exist"); - assert_eq!(status.enum_values, Some(vec![json!("complete")])); + assert_eq!( + status.enum_values, + Some(vec![json!("complete"), json!("blocked")]) + ); } } diff --git a/codex-rs/core/templates/goals/continuation.md b/codex-rs/core/templates/goals/continuation.md index 5b4bd774ba..904d15c266 100644 --- a/codex-rs/core/templates/goals/continuation.md +++ b/codex-rs/core/templates/goals/continuation.md @@ -40,4 +40,12 @@ Before deciding that the goal is achieved, treat completion as unproven and veri Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. Marking the goal complete is a claim that the full objective has been finished and can withstand requirement-by-requirement scrutiny. Only mark the goal achieved when current evidence proves every requirement has been satisfied and no required work remains. If the evidence is incomplete, weak, indirect, merely consistent with completion, or leaves any requirement missing, incomplete, or unverified, keep working instead of marking the goal complete. If the objective is achieved, call update_goal with status "complete" so usage accounting is preserved. If the achieved goal has a token budget, report the final consumed token budget to the user after update_goal succeeds. -Do not call update_goal unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. +Blocked audit: +- Do not call update_goal with status "blocked" the first time a blocker appears. +- Only use status "blocked" when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic goal continuations. +- If the user resumes a goal that was previously marked "blocked", treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, call update_goal with status "blocked" again. +- Use status "blocked" only when you are truly at an impasse and cannot make meaningful progress without user input or an external-state change. +- Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; call update_goal with status "blocked". +- Never use status "blocked" merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. + +Do not call update_goal unless the goal is complete or the strict blocked audit above is satisfied. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 2d7dfdf880..17a39816a7 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -32,6 +32,8 @@ pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; +pub const GOAL_USAGE_LIMITED_METRIC: &str = "codex.goal.usage_limited"; +pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 94753780cb..98635ee30a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3561,6 +3561,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql new file mode 100644 index 0000000000..4e89f7469b --- /dev/null +++ b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql @@ -0,0 +1,48 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE thread_goals_new ( + thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ( + 'active', + 'paused', + 'blocked', + 'usage_limited', + 'budget_limited', + 'complete' + )), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +INSERT INTO thread_goals_new ( + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +) +SELECT + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +FROM thread_goals; + +DROP TABLE thread_goals; +ALTER TABLE thread_goals_new RENAME TO thread_goals; + +PRAGMA foreign_keys=ON; diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs index e943c145f8..4806250069 100644 --- a/codex-rs/state/src/model/thread_goal.rs +++ b/codex-rs/state/src/model/thread_goal.rs @@ -12,6 +12,8 @@ use super::epoch_millis_to_datetime; pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } @@ -21,6 +23,8 @@ impl ThreadGoalStatus { match self { Self::Active => "active", Self::Paused => "paused", + Self::Blocked => "blocked", + Self::UsageLimited => "usage_limited", Self::BudgetLimited => "budget_limited", Self::Complete => "complete", } @@ -42,6 +46,8 @@ impl TryFrom<&str> for ThreadGoalStatus { match value { "active" => Ok(Self::Active), "paused" => Ok(Self::Paused), + "blocked" => Ok(Self::Blocked), + "usage_limited" => Ok(Self::UsageLimited), "budget_limited" => Ok(Self::BudgetLimited), "complete" => Ok(Self::Complete), other => Err(anyhow!("unknown thread goal status `{other}`")), diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index b43e8f69d5..9de561ef2d 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -195,7 +195,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ? ELSE ? END, @@ -209,6 +209,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(token_budget) .bind(token_budget) @@ -229,7 +230,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ? ELSE ? END, @@ -242,6 +243,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) @@ -323,6 +325,23 @@ WHERE thread_id = ? pub async fn pause_active_thread_goal( &self, thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::Paused) + .await + } + + pub async fn usage_limit_active_thread_goal( + &self, + thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::UsageLimited) + .await + } + + async fn update_active_thread_goal_status( + &self, + thread_id: ThreadId, + status: crate::ThreadGoalStatus, ) -> anyhow::Result> { let now_ms = datetime_to_epoch_millis(Utc::now()); let result = sqlx::query( @@ -332,12 +351,19 @@ SET status = ?, updated_at_ms = ? WHERE thread_id = ? - AND status = 'active' + AND ( + status = 'active' + OR ( + ? = 'usage_limited' + AND status = 'budget_limited' + ) + ) "#, ) - .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(status.as_str()) .bind(now_ms) .bind(thread_id.to_string()) + .bind(status.as_str()) .execute(self.pool.as_ref()) .await?; @@ -386,7 +412,7 @@ WHERE thread_id = ? "status IN ('active', 'budget_limited', 'complete')" } ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let budget_limit_status_filter = match mode { @@ -394,7 +420,7 @@ WHERE thread_id = ? | ThreadGoalAccountingMode::ActiveOnly | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'", ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let goal_id_filter = if expected_goal_id.is_some() { @@ -1019,6 +1045,66 @@ mod tests { ); } + #[tokio::test] + async fn usage_limit_active_thread_goal_updates_active_or_budget_limited_goals() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + let goal = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ None, + ) + .await + .expect("goal replacement should succeed"); + + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("active goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..goal + }; + assert_eq!(expected, usage_limited); + + let second_update = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("repeated usage limiting should succeed"); + assert_eq!(None, second_update); + + let budget_limited = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "keep the usage failure visible", + crate::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(1), + ) + .await + .expect("goal replacement should succeed"); + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("budget-limited goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..budget_limited + }; + assert_eq!(expected, usage_limited); + } + #[tokio::test] async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() { let runtime = test_runtime().await; @@ -1318,6 +1404,58 @@ mod tests { assert_eq!(50, paused.tokens_used); } + #[tokio::test] + async fn blocking_budget_limited_goal_preserves_terminal_status() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(40), + ) + .await + .expect("goal replacement should succeed"); + let outcome = runtime + .thread_goals() + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(budget_limited) = outcome else { + panic!("budget crossing should update the goal"); + }; + + let blocked = runtime + .thread_goals() + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + objective: None, + status: Some(crate::ThreadGoalStatus::Blocked), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + let expected = crate::ThreadGoal { + updated_at: blocked.updated_at, + ..budget_limited + }; + assert_eq!(expected, blocked); + } + #[tokio::test] async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() { let runtime = test_runtime().await; diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index e43183ffc7..89b5465a96 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -63,7 +63,10 @@ impl App { let Some(goal) = response.goal else { return; }; - if goal.status == ThreadGoalStatus::Paused { + if matches!( + goal.status, + ThreadGoalStatus::Paused | ThreadGoalStatus::Blocked | ThreadGoalStatus::UsageLimited + ) { self.chat_widget .show_resume_paused_goal_prompt(thread_id, goal.objective); } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 0b6aabf5a9..8035881e8e 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -98,6 +98,8 @@ pub(crate) enum CollaborationModeIndicator { pub(crate) enum GoalStatusIndicator { Active { usage: Option }, Paused, + Blocked, + UsageLimited, BudgetLimited { usage: Option }, Complete { usage: Option }, } @@ -547,6 +549,8 @@ pub(crate) fn goal_status_indicator_line( } } GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(), + GoalStatusIndicator::Blocked => "Goal blocked (/goal resume)".to_string(), + GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(), GoalStatusIndicator::BudgetLimited { usage } => { if let Some(usage) = usage { format!("Goal unmet ({usage})") diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index 19966d325f..ed48b5840d 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -103,7 +103,9 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec> { } let command_hint = match goal.status { AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear", - AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear", + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => "Commands: /goal edit, /goal resume, /goal clear", AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { "Commands: /goal edit, /goal clear" } @@ -117,6 +119,8 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { match status { AppThreadGoalStatus::Active => "active", AppThreadGoalStatus::Paused => "paused", + AppThreadGoalStatus::Blocked => "blocked", + AppThreadGoalStatus::UsageLimited => "usage limited", AppThreadGoalStatus::BudgetLimited => "limited by budget", AppThreadGoalStatus::Complete => "complete", } @@ -125,7 +129,9 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus { match status { AppThreadGoalStatus::Active => AppThreadGoalStatus::Active, - AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused, + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => status, AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { AppThreadGoalStatus::Active } diff --git a/codex-rs/tui/src/chatwidget/goal_status.rs b/codex-rs/tui/src/chatwidget/goal_status.rs index e641159a3b..e64c5be376 100644 --- a/codex-rs/tui/src/chatwidget/goal_status.rs +++ b/codex-rs/tui/src/chatwidget/goal_status.rs @@ -50,6 +50,8 @@ pub(super) fn goal_status_indicator_from_app_goal( usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds), }), AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused), + AppThreadGoalStatus::Blocked => Some(GoalStatusIndicator::Blocked), + AppThreadGoalStatus::UsageLimited => Some(GoalStatusIndicator::UsageLimited), AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited { usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used), }), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2a88711f48..0819bd6c03 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -375,6 +375,7 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } else { self.request_quit_without_confirmation(); @@ -392,6 +393,7 @@ impl ChatWidget { self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } } @@ -452,4 +454,24 @@ impl ChatWidget { fn is_cancellable_work_active(&self) -> bool { self.bottom_pane.is_task_running() || self.review.is_review_mode } + + fn pause_active_goal_for_interrupt(&self) { + if !self.turn_lifecycle.agent_turn_running { + return; + } + if !self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active) + { + return; + } + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SetThreadGoalStatus { + thread_id, + status: AppThreadGoalStatus::Paused, + }); + } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap new file mode 100644 index 0000000000..40de732645 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: blocked +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap new file mode 100644 index 0000000000..747ed25d27 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: usage limited +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs index e7f3077513..6ccc0220bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs @@ -28,6 +28,34 @@ async fn goal_menu_paused_snapshot() { assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx)); } +#[tokio::test] +async fn goal_menu_blocked_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::Blocked, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_blocked", rendered_goal_summary(&mut rx)); +} + +#[tokio::test] +async fn goal_menu_usage_limited_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_summary(test_goal( + thread_id, + AppThreadGoalStatus::UsageLimited, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_usage_limited", rendered_goal_summary(&mut rx)); +} + #[tokio::test] async fn goal_menu_budget_limited_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -117,6 +145,42 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() { assert!(chat.no_modal_or_popup_active()); } +#[tokio::test] +async fn goal_edit_prompt_preserves_resumable_stopped_statuses() { + for stopped_status in [ + AppThreadGoalStatus::Blocked, + AppThreadGoalStatus::UsageLimited, + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_goal_edit_prompt( + thread_id, + test_goal( + thread_id, + stopped_status, + /*token_budget*/ Some(80_000), + ), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::SetThreadGoalObjective { + mode: + crate::app_event::ThreadGoalSetMode::UpdateExisting { + status, + token_budget, + }, + .. + }) => { + assert_eq!(status, stopped_status); + assert_eq!(token_budget, Some(80_000)); + } + other => panic!("expected SetThreadGoalObjective event, got {other:?}"), + } + } +} + #[tokio::test] async fn goal_edit_prompt_resets_terminal_status_to_active() { let cases = [ diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index acd6b7111b..ac3e236822 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1136,6 +1136,45 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } +#[tokio::test] +async fn ctrl_c_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(thread_id); + let mut goal = test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + ); + goal.thread_id = thread_id.to_string(); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + ), + /*replay_kind*/ None, + ); + chat.on_task_started(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SetThreadGoalStatus { + thread_id: event_thread_id, + status: AppThreadGoalStatus::Paused, + }) if event_thread_id == thread_id + ); +} + #[tokio::test] async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2293,6 +2332,22 @@ fn goal_status_indicator_formats_statuses_and_budgets() { usage: Some("30m".to_string()), }) ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Blocked, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::Blocked) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::UsageLimited, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::UsageLimited) + ); assert_eq!( goal_status_indicator_from_app_goal(&test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, @@ -2339,6 +2394,11 @@ fn goal_status_indicator_line_formats_goal_text() { "Goal unmet (4K / 5K tokens)", ), (GoalStatusIndicator::Paused, "Goal paused (/goal resume)"), + (GoalStatusIndicator::Blocked, "Goal blocked (/goal resume)"), + ( + GoalStatusIndicator::UsageLimited, + "Goal hit usage limits (/goal resume)", + ), ( GoalStatusIndicator::BudgetLimited { usage: None }, "Goal abandoned", diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index d0455054b7..961b87f16e 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -32,6 +32,8 @@ pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str { match status { ThreadGoalStatus::Active => "active", ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage limited", ThreadGoalStatus::BudgetLimited => "limited by budget", ThreadGoalStatus::Complete => "complete", } From 4ac3ea20a28d8b5239e57ca01f0f4a0acf59baaa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:32:02 -0700 Subject: [PATCH 069/114] Clarify resume hints for renamed threads (#23234) Addresses #23181 ## Why Renamed threads can share names, so hints that suggest resuming directly by name are ambiguous. Issue #23181 asks for the picker hint to include the thread name and thread ID in parens so users can disambiguate safely. ## What - Adds a shared resume hint formatter for named threads: run `codex resume`, then select ` ()`. - Uses that hint for /rename confirmations, TUI session summaries, and CLI/TUI exit messages. - Keeps direct `codex resume ` guidance for unnamed threads. ## Verification Manually verified that message after `/rename` and after `/exit` include session ID in parens. --------- Co-authored-by: Felipe Coury --- codex-rs/cli/src/main.rs | 10 ++--- codex-rs/tui/src/app.rs | 13 ++++--- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/session_lifecycle.rs | 4 +- codex-rs/tui/src/app/tests/session_summary.rs | 11 ++++-- codex-rs/tui/src/chatwidget.rs | 14 +++---- ...tests__thread_name_update_resume_hint.snap | 5 +++ .../tui/src/chatwidget/tests/app_server.rs | 6 +-- codex-rs/tui/src/main.rs | 5 ++- codex-rs/tui/src/resume_picker.rs | 16 ++++---- codex-rs/utils/cli/src/lib.rs | 1 + codex-rs/utils/cli/src/resume_command.rs | 39 +++++++++++++++++++ 12 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8274510b9f..4b0d2330a6 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -38,7 +38,7 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -629,6 +629,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec, ) -> Option { let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); - let thread_id = - resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); - let resume_command = codex_utils_cli::resume_command(/*thread_name*/ None, thread_id); + let resumable_thread = resumable_thread(thread_id, thread_name, rollout_path); + let resume_hint = resumable_thread.as_ref().and_then(|thread| { + codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id)) + }); - if usage_line.is_none() && resume_command.is_none() { + if usage_line.is_none() && resume_hint.is_none() { return None; } Some(SessionSummary { usage_line, - resume_command, + resume_hint, }) } @@ -459,7 +460,7 @@ fn errors_for_cwd(cwd: &Path, response: &SkillsListResponse) -> Vec, - resume_command: Option, + resume_hint: Option, } #[derive(Debug, Default)] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d1ce849c04..ddb11c4520 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -152,7 +152,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec![ "To continue this session, run ".into(), command.cyan(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 75af2f1da9..1a57b596b0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -473,7 +473,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } @@ -701,7 +701,7 @@ impl App { if let Some(usage_line) = summary.usage_line { lines.push(usage_line.into()); } - if let Some(command) = summary.resume_command { + if let Some(command) = summary.resume_hint { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); diff --git a/codex-rs/tui/src/app/tests/session_summary.rs b/codex-rs/tui/src/app/tests/session_summary.rs index 2a96f9ad3d..63ff92a097 100644 --- a/codex-rs/tui/src/app/tests/session_summary.rs +++ b/codex-rs/tui/src/app/tests/session_summary.rs @@ -57,13 +57,13 @@ async fn session_summary_includes_resume_hint_for_persisted_rollout() { Some("Token usage: total=12 input=10 output=2".to_string()) ); assert_eq!( - summary.resume_command, + summary.resume_hint, Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } #[tokio::test] -async fn session_summary_uses_id_even_when_thread_has_name() { +async fn session_summary_names_picker_item_when_thread_has_name() { let usage = TokenUsage { input_tokens: 10, output_tokens: 2, @@ -83,7 +83,10 @@ async fn session_summary_uses_id_even_when_thread_has_name() { ) .expect("summary"); assert_eq!( - summary.resume_command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + summary.resume_hint, + Some( + "codex resume, then select my-session (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8c84748dde..d420162914 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -166,7 +166,7 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1440,16 +1440,14 @@ impl ChatWidget { } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let resume_cmd = - resume_command(Some(name), thread_id).unwrap_or_else(|| format!("codex resume {name}")); - let name = name.to_string(); - let line = vec![ + let mut line = vec![ "• ".into(), "Thread renamed to ".into(), - name.cyan(), - ", to resume this thread run ".into(), - resume_cmd.cyan(), + name.to_string().cyan(), ]; + if let Some(hint) = resume_hint(Some(name), thread_id) { + line.extend([". To resume this thread run ".into(), hint.cyan()]); + } PlainHistoryCell::new(vec![line.into()]) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap new file mode 100644 index 0000000000..b3cdf89da3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: rendered +--- +• Thread renamed to review-fix. To resume this thread run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 688e30bd17..91c5b1d1fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -861,7 +861,8 @@ async fn live_app_server_invalid_thread_name_update_is_ignored() { #[tokio::test] async fn live_app_server_thread_name_update_shows_resume_hint() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let thread_id = ThreadId::new(); + let thread_id = + ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); chat.thread_id = Some(thread_id); chat.handle_server_notification( @@ -878,8 +879,7 @@ async fn live_app_server_thread_name_update_shows_resume_hint() { let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); - assert!(rendered.contains("Thread renamed to review-fix")); - assert!(rendered.contains("codex resume review-fix")); + assert_chatwidget_snapshot!("thread_name_update_resume_hint", rendered); } #[tokio::test] diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index e41d5f5a7d..9c69c7c81a 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -7,13 +7,14 @@ use codex_tui::Cli; use codex_tui::ExitReason; use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; -use codex_utils_cli::resume_command; +use codex_utils_cli::resume_hint; use supports_color::Stream; fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, thread_id, + thread_name, .. } = exit_info; @@ -22,7 +23,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec> { let reference = state.relative_time_reference.unwrap_or_else(Utc::now); - let session = row - .thread_name - .as_deref() - .map(str::to_string) - .or_else(|| row.thread_id.map(|thread_id| thread_id.to_string())) - .unwrap_or_else(|| "-".to_string()); + let session = match (row.thread_name.as_deref(), row.thread_id) { + (Some(thread_name), Some(thread_id)) => format!("{thread_name} ({thread_id})"), + (Some(thread_name), None) => thread_name.to_string(), + (None, Some(thread_id)) => thread_id.to_string(), + (None, None) => "-".to_string(), + }; let directory = row .cwd .as_ref() @@ -3387,7 +3387,9 @@ mod tests { let expected_directory = format_directory_display(row.cwd.as_deref().expect("cwd"), /*max_width*/ None); - assert!(rendered.contains("Session: feat(tui): add raw scrollback mode")); + assert!(rendered.contains( + "Session: feat(tui): add raw scrollback mode (019dabc1-0ef5-7431-b81c-03037f51f62c)" + )); assert!(rendered.contains("Created: 17 minutes ago · 2026-05-02 14:31:08")); assert!(rendered.contains("Updated: now · 2026-05-02 14:48:19")); assert!(rendered.contains(&format!("Directory: {expected_directory}"))); diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 32ef52e095..633638e72d 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -10,5 +10,6 @@ pub use codex_protocol::config_types::ProfileV2Name; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; pub use resume_command::resume_command; +pub use resume_command::resume_hint; pub use sandbox_mode_cli_arg::SandboxModeCliArg; pub use shared_options::SharedCliOptions; diff --git a/codex-rs/utils/cli/src/resume_command.rs b/codex-rs/utils/cli/src/resume_command.rs index 52b88e0e33..d3c6839057 100644 --- a/codex-rs/utils/cli/src/resume_command.rs +++ b/codex-rs/utils/cli/src/resume_command.rs @@ -19,6 +19,16 @@ pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> }) } +pub fn resume_hint(thread_name: Option<&str>, thread_id: Option) -> Option { + let thread_id = thread_id?; + match thread_name.filter(|name| !name.is_empty()) { + Some(thread_name) => Some(format!( + "codex resume, then select {thread_name} ({thread_id})" + )), + None => resume_command(/*thread_name*/ None, Some(thread_id)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -61,4 +71,33 @@ mod tests { let command = resume_command(Some("quote'case"), /*thread_id*/ None); assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); } + + #[test] + fn resume_hint_names_picker_item_with_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(Some("my-thread"), Some(thread_id)); + assert_eq!( + hint, + Some( + "codex resume, then select my-thread (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) + ); + } + + #[test] + fn resume_hint_uses_direct_id_command_without_name() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let hint = resume_hint(/*thread_name*/ None, Some(thread_id)); + assert_eq!( + hint, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn resume_hint_requires_thread_id() { + let hint = resume_hint(Some("my-thread"), /*thread_id*/ None); + assert_eq!(hint, None); + } } From 53a1f4c29eeef868202030bc8b117152d98f7b57 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:33:13 -0700 Subject: [PATCH 070/114] TUI: route elicitation responses to request thread (#23241) ## Why Fixes #21894. When the TUI handles an MCP elicitation, the request payload already includes the thread that generated the elicitation. `ChatWidget::handle_elicitation_request_now` was ignoring that value and using the currently visible chat thread instead. In a multi-session TUI, that can send `resolve_elicitation` to an older visible thread rather than the session that owns the pending elicitation, producing `elicitation request not found` and leaving the prompt unresolved. ## What changed - Parse `McpServerElicitationRequestParams.thread_id` in the ChatWidget elicitation handler and use it for app-link, form, fallback approval, and auto-decline resolution paths. - Keep the existing visible-thread fallback only for malformed request payloads with an invalid thread id. - Update the invalid URL elicitation regression test so the visible thread and request thread intentionally differ. --- codex-rs/tui/src/chatwidget/tests/app_server.rs | 9 +++++---- codex-rs/tui/src/chatwidget/tool_requests.rs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 91c5b1d1fc..0b5f989141 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -4,13 +4,14 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; - let thread_id = ThreadId::new(); - chat.thread_id = Some(thread_id); + let visible_thread_id = ThreadId::new(); + let request_thread_id = ThreadId::new(); + chat.thread_id = Some(visible_thread_id); chat.handle_elicitation_request_now( codex_app_server_protocol::RequestId::Integer(9), codex_app_server_protocol::McpServerElicitationRequestParams { - thread_id: thread_id.to_string(), + thread_id: request_thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), request: codex_app_server_protocol::McpServerElicitationRequest::Url { @@ -33,7 +34,7 @@ async fn invalid_url_elicitation_is_declined() { content: None, meta: None, }, - }) if op_thread_id == thread_id && server_name == "payments" + }) if op_thread_id == request_thread_id && server_name == "payments" ); } diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index de8fe6a704..c5c425b64f 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -341,7 +341,8 @@ impl ChatWidget { server_name: params.server_name.clone(), }); - let thread_id = self.thread_id.unwrap_or_default(); + let thread_id = ThreadId::from_string(¶ms.thread_id) + .unwrap_or_else(|_| self.thread_id.unwrap_or_default()); if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, From ae03d073b3e0d4227b8646e09872baa157408cdf Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:34:31 -0700 Subject: [PATCH 071/114] TUI: replay in-progress MCP calls as started (#23236) Fixes #22300. ## Summary MCP tool calls can appear in thread history while still in progress. During replay, `handle_thread_item` routed every `ThreadItem::McpToolCall` to the completion handler, so an in-progress item with no result or error was rendered as `MCP tool call completed without a result`. This updates replay handling to mirror command executions: `InProgress` MCP calls go through `on_mcp_tool_call_started`, while completed and failed calls continue through the completion path. ## Validation - `cargo test -p codex-tui replayed_in_progress_mcp_tool_call_stays_active` --- codex-rs/tui/src/chatwidget/replay.rs | 4 +++ .../src/chatwidget/tests/history_replay.rs | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 294e07e33a..f0211aaca1 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -133,6 +133,10 @@ impl ChatWidget { .. } => {} item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItem::McpToolCall { + status: codex_app_server_protocol::McpToolCallStatus::InProgress, + .. + } => self.on_mcp_tool_call_started(item), item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), ThreadItem::WebSearch { id, query, action } => { self.on_web_search_begin(id.clone()); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 36b3e0189e..628c5abb43 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -826,6 +826,33 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { assert!(rendered.contains("Raw reasoning")); } +#[tokio::test] +async fn replayed_in_progress_mcp_tool_call_stays_active() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let _ = drain_insert_history(&mut rx); + + chat.replay_thread_item( + AppServerThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "copilot-bridge".to_string(), + tool: "copilot".to_string(), + status: codex_app_server_protocol::McpToolCallStatus::InProgress, + arguments: json!({"action": "wait"}), + mcp_app_resource_uri: None, + result: None, + error: None, + duration_ms: None, + }, + "turn-1".to_string(), + ReplayKind::ThreadSnapshot, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + let active = active_blob(&chat); + assert!(active.contains("Calling")); + assert!(!active.contains("MCP tool call completed without a result")); +} + #[tokio::test] async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 55f6bbc6672a97efe1321318120b2054bf6b841f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 11:58:40 -0700 Subject: [PATCH 072/114] goals: keep pause transitions explicit (#23088) ## Problem This addresses several user-reported cases where active goals were paused even though the user had not explicitly asked for that transition: - the guardian approval-review circuit breaker interrupted a turn and implicitly paused the goal - a shutdown in one app-server instance could pause a goal while a second instance was still actively running the same thread - steering-style interrupts could also pause the goal even though they are meant to redirect work, not stop the goal lifecycle The common problem was that core treated `TurnAbortReason::Interrupted` as an implicit request to transition the persisted goal to `paused`. That made unrelated interrupt paths mutate goal state as a side effect, and in the multi-app-server case it allowed stale process teardown to pause a live goal owned by another running client. After this change, transitioning a goal to `paused` is always an explicit action performed by a client or another intentional goal-state mutation. It is never an implicit transition triggered by generic interrupt handling. Refs #22884. ## What changed - Remove the goal runtime path that paused active goals after interrupted task aborts. - Drop the now-unused abort reason from `GoalRuntimeEvent::TaskAborted`. - Update the focused regression coverage so an interrupted active goal still accounts usage but remains `active`. --- codex-rs/core/src/goals.rs | 78 ++---------------------------- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/tests.rs | 32 +++++++++++- codex-rs/core/src/tasks/mod.rs | 2 - 4 files changed, 35 insertions(+), 78 deletions(-) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 0f3b8e36eb..18b7c77bda 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -26,13 +26,11 @@ use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; -use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::validate_thread_goal_objective; use codex_rollout::state_db::reconcile_rollout; use codex_thread_store::LocalThreadStore; @@ -156,7 +154,6 @@ pub(crate) enum GoalRuntimeEvent<'a> { MaybeContinueIfIdle, TaskAborted { turn_context: Option<&'a TurnContext>, - reason: TurnAbortReason, }, UsageLimitReached { turn_context: &'a TurnContext, @@ -337,8 +334,8 @@ impl Session { /// starts capture the active goal and token baseline, tool completions /// account usage and may inject budget steering, completion accounting /// suppresses that steering, external mutations account best-effort before - /// changing state, interrupts pause active goals, thread resumes restore - /// runtime state for already-active goals, explicit maybe-continue events + /// changing state, thread resumes restore runtime state for already-active + /// goals, explicit maybe-continue events /// start idle goal continuation turns, and continuation turns with no counted /// autonomous activity suppress the next automatic continuation until /// user/tool/external activity resets it. @@ -390,12 +387,8 @@ impl Session { self.maybe_continue_goal_if_idle_runtime().await; Ok(()) }), - GoalRuntimeEvent::TaskAborted { - turn_context, - reason, - } => Box::pin(async move { - self.handle_thread_goal_task_abort(turn_context, reason) - .await; + GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move { + self.handle_thread_goal_task_abort(turn_context).await; Ok(()) }), GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { @@ -947,11 +940,7 @@ impl Session { } } - async fn handle_thread_goal_task_abort( - &self, - turn_context: Option<&TurnContext>, - reason: TurnAbortReason, - ) { + async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { if let Some(turn_context) = turn_context { self.take_thread_goal_continuation_turn(&turn_context.sub_id) .await; @@ -974,12 +963,6 @@ impl Session { accounting.turn = None; } } - - if reason == TurnAbortReason::Interrupted - && let Err(err) = self.pause_active_thread_goal_for_interrupt().await - { - tracing::warn!("failed to pause active thread goal after interrupt: {err}"); - } } async fn account_thread_goal_progress( @@ -1187,57 +1170,6 @@ impl Session { } } - async fn pause_active_thread_goal_for_interrupt(&self) -> anyhow::Result<()> { - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - return Ok(()); - } - - if !self.enabled(Feature::Goals) { - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::ThreadGoalAccountingMode::ActiveStatusOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let Some(goal) = state_db - .thread_goals() - .pause_active_thread_goal(self.conversation_id) - .await? - else { - return Ok(()); - }; - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .clear_active_goal(); - self.send_event_raw(Event { - id: uuid::Uuid::new_v4().to_string(), - msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.conversation_id, - turn_id: None, - goal, - }), - }) - .await; - Ok(()) - } - async fn usage_limit_active_thread_goal_for_turn( &self, turn_context: &TurnContext, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 315dc93c6f..02d9e75d4d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3355,7 +3355,6 @@ impl Session { pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); let had_active_turn = self.active_turn.lock().await.is_some(); - // Even without an active task, interrupt handling pauses any active goal. self.abort_all_tasks(TurnAbortReason::Interrupted).await; if !had_active_turn { self.cancel_mcp_startup().await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d3f6917775..dbdc8aa8dd 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8243,7 +8243,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { } #[tokio::test] -async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { +async fn interrupt_accounts_active_goal_without_pausing() -> anyhow::Result<()> { let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), @@ -8273,7 +8273,7 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { .await? .expect("goal should remain persisted after interrupt"); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Paused, + codex_protocol::protocol::ThreadGoalStatus::Active, goal.status ); assert_eq!(70, goal.tokens_used); @@ -8283,6 +8283,34 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn shutdown_without_active_turn_keeps_active_goal_active() -> anyhow::Result<()> { + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + sess.set_thread_goal( + tc.as_ref(), + SetGoalRequest { + objective: Some("Keep improving the benchmark".to_string()), + status: None, + token_budget: None, + }, + ) + .await?; + + assert!(sess.active_turn.lock().await.is_none()); + assert!(handlers::shutdown(&sess, "shutdown".to_string()).await); + + let goal = sess + .get_thread_goal() + .await? + .expect("goal should remain persisted after shutdown"); + assert_eq!( + codex_protocol::protocol::ThreadGoalStatus::Active, + goal.status + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 7724d5f2d7..18f650e925 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -513,7 +513,6 @@ impl Session { && let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { turn_context: turn_context.as_deref(), - reason: reason.clone(), }) .await { @@ -561,7 +560,6 @@ impl Session { if let Err(err) = self .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { turn_context: turn_context.as_deref(), - reason: reason.clone(), }) .await { From 8e52578e665b525940adc8fd62e71446baf25b41 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 16:04:41 -0300 Subject: [PATCH 073/114] feat(tui): handle paste in session picker (#23338) ## Why The session picker already supports typed search, but it ignored bracketed paste events entirely. On macOS terminals this makes pasted text look like a no-op on the resume screen, which is especially noticeable when a user wants to paste part of a thread name, branch, or path into the search field. ## What Changed - route `TuiEvent::Paste(String)` into the session picker instead of dropping it - normalize pasted search text into a single-line query by collapsing whitespace - ignore whitespace-only pastes - reuse the existing `set_query(...)` path so pasted searches keep the same filtering and pagination behavior as typed input - add focused tests for append behavior, whitespace normalization, whitespace-only paste, and the existing search-loading path This PR is stacked on top of #23234 and contains only the net change relative to `etraut/clarify-resume-hints`. ## How to Test 1. Start Codex in a terminal that emits bracketed paste, for example iTerm2 on macOS. 2. Open the resume picker so the search UI is visible. 3. Copy a term that should match one of the visible sessions, then paste it into the picker. 4. Confirm the query updates immediately and the list filters as if the text had been typed. 5. Also verify that pasting text with newlines or tabs still produces a usable single-line search query. 6. Also verify that normal typed search still works and that `Esc` still clears the query / exits as before. Targeted tests: - `cargo test -p codex-tui` --------- Co-authored-by: Eric Traut --- codex-rs/tui/src/resume_picker.rs | 105 +++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 655c0abd9a..c7be652f85 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -477,6 +477,9 @@ async fn run_session_picker_with_loader( return Ok(sel); } } + TuiEvent::Paste(pasted) => { + state.handle_paste(pasted); + } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = @@ -489,7 +492,6 @@ async fn run_session_picker_with_loader( state.open_pending_transcript_if_ready(); } } - _ => {} } } Some(event) = background_events.next() => { @@ -543,6 +545,11 @@ fn picker_cwd_filter( } } +fn normalize_pasted_query(pasted: &str) -> Option { + let normalized = pasted.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) +} + fn spawn_app_server_page_loader( app_server: AppServerSession, include_non_interactive: bool, @@ -1227,6 +1234,21 @@ impl PickerState { Ok(None) } + fn handle_paste(&mut self, pasted: String) { + if self.is_transcript_loading() { + return; + } + let Some(pasted) = normalize_pasted_query(&pasted) else { + return; + }; + let mut new_query = self.query.clone(); + if !new_query.is_empty() && !new_query.ends_with(char::is_whitespace) { + new_query.push(' '); + } + new_query.push_str(&pasted); + self.set_query(new_query); + } + fn start_initial_load(&mut self) { self.relative_time_reference = Some(Utc::now()); self.reset_pagination(); @@ -6218,6 +6240,87 @@ session_picker_view = "dense" assert!(state.pagination.reached_scan_cap); } + #[tokio::test] + async fn paste_appends_to_existing_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from("results")); + + assert_eq!(state.query, "resize results"); + } + + #[test] + fn normalize_pasted_query_collapses_whitespace() { + assert_eq!( + normalize_pasted_query(" alpha\n\tbeta\r\n gamma "), + Some(String::from("alpha beta gamma")) + ); + } + + #[tokio::test] + async fn whitespace_only_paste_is_ignored() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state.handle_paste(String::from(" \n\t ")); + + assert_eq!(state.query, "resize"); + } + + #[tokio::test] + async fn paste_uses_existing_search_loading_path() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![make_row( + "/tmp/start.jsonl", + "2025-01-01T00:00:00Z", + "alpha", + )], + Some("2025-01-02T00:00:00Z"), + /*num_scanned_files*/ 1, + /*reached_scan_cap*/ false, + )); + recorded_requests.lock().unwrap().clear(); + + state.handle_paste(String::from("target")); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(state.query, "target"); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_some()); + } + #[tokio::test] async fn esc_with_empty_query_starts_fresh() { let loader = page_only_loader(|_| {}); From 139365a4bbe7b956b6be428d5bfeb6f658a3156d Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 18 May 2026 12:12:14 -0700 Subject: [PATCH 074/114] feat(app-server): add optional thread_id to experimentalFeature/list (#23335) ## Why `experimentalFeature/list` reports effective feature enablement, but currently does not resolve it against a working directory where project-local config.toml files can exist and toggle on/off features when merged into the effective config after resolving the various config layers. That means we effectively (and incorrectly) ignore features set in project-local config. To address that, this PR exposes an optional `thread_id` param which allows us to load the thread's `cwd. ## Testing - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server experimental_feature_list` --- .../schema/json/ClientRequest.json | 7 ++ .../codex_app_server_protocol.schemas.json | 7 ++ .../codex_app_server_protocol.v2.schemas.json | 7 ++ .../v2/ExperimentalFeatureListParams.json | 7 ++ .../v2/ExperimentalFeatureListParams.ts | 8 +- .../src/protocol/common.rs | 28 ++++- .../src/protocol/v2/experimental_feature.rs | 5 + codex-rs/app-server/README.md | 2 +- .../request_processors/catalog_processor.rs | 24 ++++- .../suite/v2/experimental_feature_list.rs | 101 ++++++++++++++++++ 10 files changed, 191 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6678d9c0d1..b976c9e4ef 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -611,6 +611,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "type": "object" 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 c4bb4a2ff4..fdaaf3e857 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 @@ -8194,6 +8194,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", 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 c176c2a4b8..e413d79234 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 @@ -4583,6 +4583,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json index ab562edbf2..13fa2efb98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -16,6 +16,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts index 0a1555d998..c98425b849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts @@ -10,4 +10,10 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Optional loaded thread id. Pass this when showing feature state for an + * existing thread so enablement is computed from that thread's refreshed + * config, including project-local config for the thread's cwd. + */ +threadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a08de3e528..4e6cef308e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2721,7 +2721,33 @@ mod tests { "id": 8, "params": { "cursor": null, - "limit": null + "limit": null, + "threadId": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_list_experimental_features_with_thread_id() -> Result<()> { + let request = ClientRequest::ExperimentalFeatureList { + request_id: RequestId::Integer(8), + params: v2::ExperimentalFeatureListParams { + cursor: Some("3".to_string()), + limit: Some(2), + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "experimentalFeature/list", + "id": 8, + "params": { + "cursor": "3", + "limit": 2, + "threadId": "00000000-0000-4000-8000-000000000001" } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs index 6adc21b6ef..a16f45216b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -14,6 +14,11 @@ pub struct ExperimentalFeatureListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Optional loaded thread id. Pass this when showing feature state for an + /// existing thread so enablement is computed from that thread's refreshed + /// config, including project-local config for the thread's cwd. + #[ts(optional = nullable)] + pub thread_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5fa61b3c17..4f9cb7c344 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. -- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 91fabf7616..834f70654d 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -285,8 +285,28 @@ impl CatalogRequestProcessor { &self, params: ExperimentalFeatureListParams, ) -> Result { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let ExperimentalFeatureListParams { + cursor, + limit, + thread_id, + } = params; + let config = match thread_id.as_deref() { + Some(thread_id) => { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d50683cf80..279fa60da1 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ConfigReadParams; @@ -16,6 +17,8 @@ use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -156,6 +159,104 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ Ok(()) } +#[tokio::test] +async fn experimental_feature_list_resolves_thread_project_config() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let server_uri = server.uri(); + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" + +[projects."{workspace_key}"] +trust_level = "trusted" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#"[features] +memories = true +"#, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + read_response::(&mut mcp, thread_start_id).await?; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some(thread.id), + }) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let memories = actual + .data + .iter() + .find(|feature| feature.name == "memories") + .expect("memories feature should be present"); + assert!(memories.enabled); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("thread not found: 00000000-0000-4000-8000-000000000001"), + "{}", + error.message + ); + + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() -> Result<()> { From 10f7dc6eb544a076af0cd45748bf43636ed3e0e0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 12:26:10 -0700 Subject: [PATCH 075/114] codex: route global AGENTS reads through LOCAL_FS (#23343) ## Summary - make `load_global_instructions` read through an `ExecutorFileSystem` - call global AGENTS reads with explicit `LOCAL_FS` so they stay tied to local codex-home state ## Validation - `bazel test --bes_backend= --bes_results_url= --test_filter=instruction_sources_include_global_before_agents_md_docs //codex-rs/core:core-unit-tests` on `dev` --- codex-rs/core/src/agents_md.rs | 14 +++++++++----- codex-rs/core/src/config/mod.rs | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 7a9fd74932..2c635e7002 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,6 +23,7 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize as normalize_path; @@ -58,13 +59,14 @@ impl<'a> AgentsMdManager<'a> { Self { config } } - pub(crate) fn load_global_instructions( + pub(crate) async fn load_global_instructions( + fs: &dyn ExecutorFileSystem, codex_dir: Option<&AbsolutePathBuf>, ) -> Option { let base = codex_dir?; for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { let path = base.join(candidate); - if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await { let trimmed = contents.trim(); if !trimmed.is_empty() { return Some(LoadedAgentsMd { @@ -128,9 +130,11 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut paths = Self::load_global_instructions(Some(&self.config.codex_home)) - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); + let mut paths = + Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home)) + .await + .map(|loaded| vec![loaded.path]) + .unwrap_or_default(); match self.agents_md_paths(fs).await { Ok(agents_md_paths) => paths.extend(agents_md_paths), Err(err) => { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 71a55b98dc..6eb66c4543 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2417,8 +2417,10 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home)) - .map(|loaded| loaded.contents); + let user_instructions = + AgentsMdManager::load_global_instructions(LOCAL_FS.as_ref(), Some(&codex_home)) + .await + .map(|loaded| loaded.contents); let mut startup_warnings = config_layer_stack .startup_warnings() .unwrap_or_default() From 4dbca61e200d0e1f048e875e87dd3f12a69f95c3 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 18 May 2026 12:41:10 -0700 Subject: [PATCH 076/114] fix: default unknown tool schemas to empty schemas (#22380) ## Why Some tool providers, especially MCP servers and dynamic tool sources, can supply schema nodes that omit `type` and have no recognized JSON Schema shape hints. Previously, `sanitize_json_schema` filled those unknown nodes in as `string`, which made the schema parseable but invented a scalar constraint that the provider did not specify. For description-only fields, that could incorrectly steer tool arguments away from the provider's actual accepted shape. The Responses API accepts permissive empty schemas such as `{}` at nested property positions, so Codex should preserve that permissive meaning instead of coercing unknown schema nodes into a misleading scalar type. ## What Changed - Changed the no-hints fallback in `codex-rs/tools/src/json_schema.rs` to clear unrecognized object schema nodes to `{}`. - Empty schemas now remain `{}` rather than becoming `type: "string"`. - Description-only or otherwise metadata-only nested property schemas now become `{}` while surrounding object/array/string/number inference still applies when recognized hints are present. - Updated `codex-tools` and `codex-core` tests to cover top-level empty schemas, nested empty schemas, metadata-only malformed schemas, dynamic tools, and MCP tool specs. ## Verification - `cargo test -p codex-tools` - `cargo test -p codex-core test_mcp_tool_property_missing_type_defaults_to_empty_schema` - Manually verified the real Responses API behavior for both empty-schema positions: - Top-level function `parameters: {}` is accepted and echoed back as `{"type":"object","properties":{}}`; when forced to call the tool, Responses emitted empty object arguments: `"arguments": "{}"`. - Nested property schema `{}` is accepted and preserved as `{}`; when forced to call a tool with `metadata.extra`, Responses emitted `"arguments": "{\"metadata\":{\"extra\":\"codex schema sanitizer behavior\"}}"`. --- .../core/src/tools/spec_plan_model_tests.rs | 7 +- codex-rs/tools/src/dynamic_tool_tests.rs | 5 +- codex-rs/tools/src/json_schema.rs | 4 +- codex-rs/tools/src/json_schema_tests.rs | 82 +++++++++++++++++-- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index 86692e48df..3390d26e0d 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -907,7 +907,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio } #[tokio::test] -async fn test_mcp_tool_property_missing_type_defaults_to_string() { +async fn test_mcp_tool_property_missing_type_defaults_to_empty_schema() { let config = test_config().await; let model_info = construct_model_info_offline("gpt-5.4", &config); let mut features = Features::with_defaults(); @@ -950,10 +950,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { name: "search".to_string(), parameters: JsonSchema::object( /*properties*/ - BTreeMap::from([( - "query".to_string(), - JsonSchema::string(Some("search query".to_string())), - )]), + BTreeMap::from([("query".to_string(), JsonSchema::default())]), /*required*/ None, /*additional_properties*/ None ), diff --git a/codex-rs/tools/src/dynamic_tool_tests.rs b/codex-rs/tools/src/dynamic_tool_tests.rs index 077a0622ce..284ce4853b 100644 --- a/codex-rs/tools/src/dynamic_tool_tests.rs +++ b/codex-rs/tools/src/dynamic_tool_tests.rs @@ -27,10 +27,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() { name: "lookup_ticket".to_string(), description: "Fetch a ticket".to_string(), input_schema: JsonSchema::object( - BTreeMap::from([( - "id".to_string(), - JsonSchema::string(Some("Ticket identifier".to_string()),), - )]), + BTreeMap::from([("id".to_string(), JsonSchema::default(),)]), /*required*/ None, /*additional_properties*/ None ), diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 22a641491e..ecd880cc7a 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -166,6 +166,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { @@ -228,7 +229,8 @@ fn sanitize_json_schema(value: &mut JsonValue) { { schema_types.push(JsonSchemaPrimitiveType::Number); } else { - schema_types.push(JsonSchemaPrimitiveType::String); + map.clear(); + return; } } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 3f13df7638..5daaf048d0 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -34,7 +34,8 @@ fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // // Expected normalization behavior: // - `properties` implies an object schema when `type` is omitted. - // - The child property keeps its description and defaults to a string type. + // - The child property has no recognized schema hints, so it is coerced to + // an empty permissive schema. let schema = parse_tool_input_schema(&serde_json::json!({ "properties": { "query": {"description": "search query"} @@ -45,16 +46,33 @@ fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { assert_eq!( schema, JsonSchema::object( - BTreeMap::from([( - "query".to_string(), - JsonSchema::string(Some("search query".to_string())), - )]), + BTreeMap::from([("query".to_string(), JsonSchema::default())]), /*required*/ None, /*additional_properties*/ None ) ); } +#[test] +fn parse_tool_input_schema_coerces_unrecognized_object_schema_to_empty_schema() { + // Example schema shape: + // { + // "description": "Ticket identifier", + // "title": "Ticket ID" + // } + // + // Expected normalization behavior: + // - Object schemas with no recognized schema hints are treated as + // malformed and coerced to the empty permissive schema. + let schema = parse_tool_input_schema(&serde_json::json!({ + "description": "Ticket identifier", + "title": "Ticket ID" + })) + .expect("parse schema"); + + assert_eq!(schema, JsonSchema::default()); +} + #[test] fn parse_tool_input_schema_preserves_integer_and_defaults_array_items() { // Example schema shape: @@ -250,16 +268,62 @@ fn parse_tool_input_schema_infers_string_from_enum_const_and_format_keywords() { } #[test] -fn parse_tool_input_schema_defaults_empty_schema_to_string() { +fn parse_tool_input_schema_preserves_empty_schema() { // Example schema shape: // {} // // Expected normalization behavior: - // - With no structural hints at all, the normalizer falls back to a - // permissive string schema. + // - An empty JSON Schema is already a valid permissive schema, so it stays + // empty rather than being rewritten as an object schema. let schema = parse_tool_input_schema(&serde_json::json!({})).expect("parse schema"); - assert_eq!(schema, JsonSchema::string(/*description*/ None)); + assert_eq!(schema, JsonSchema::default()); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_empty_schema() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "metadata": { + // "properties": { + // "extra": {} + // } + // } + // } + // } + // + // Expected normalization behavior: + // - The sanitizer recurses through nested object properties. + // - The innermost `extra` field is an empty JSON Schema and stays empty. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "metadata": { + "properties": { + "extra": {} + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::object( + BTreeMap::from([( + "metadata".to_string(), + JsonSchema::object( + BTreeMap::from([("extra".to_string(), JsonSchema::default())]), + /*required*/ None, + /*additional_properties*/ None, + ) + )]), + /*required*/ None, + /*additional_properties*/ None, + ) + ); } #[test] From c69cde3547c87c3423434ff37273dcadbcce8817 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 18 May 2026 21:55:57 +0200 Subject: [PATCH 077/114] Add tool lifecycle extension contributor (#23309) ## Why Extensions that need to track runtime progress currently have no typed host signal for tool execution. The goal extension in particular needs to observe tool attempts without inspecting tool payloads, owning tool implementations, or staying coupled to core-only runtime plumbing. This adds a narrow lifecycle contributor API for host-owned tool execution: extensions can observe when an accepted tool call starts and how it finishes, while policy hooks and tool handlers continue to own payload rewriting, blocking, and execution. Relevant code: - [`ToolLifecycleContributor`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/ext/extension-api/src/contributors.rs#L119) defines the extension-facing observer contract. - [`tool_lifecycle.rs`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs) defines the typed start/finish inputs, source, and outcome enums. - [`notify_tool_start` / `notify_tool_finish`](https://github.com/openai/codex/blob/3ad2850ffc7d8a1da19c65a92425637a59098f1b/codex-rs/core/src/tools/lifecycle.rs) bridges core tool dispatch into the extension registry. ## What Changed - Added `ToolLifecycleContributor` to `codex-extension-api`, including: - `ToolStartInput` - `ToolFinishInput` - `ToolCallSource` - `ToolCallOutcome` - Added registration and lookup support on `ExtensionRegistryBuilder` / `ExtensionRegistry`. - Wired core tool dispatch to notify lifecycle contributors for: - accepted tool starts - completed tool calls, including the tool output success marker - pre-tool-use blocks - failures before or after the handler runs - cancellation/abort in the parallel tool path - Registered the goal extension as a lifecycle contributor and added the outcome filter it will use for goal progress accounting. ## Test Coverage - Added `dispatch_notifies_tool_lifecycle_contributors` to cover lifecycle notification ordering and outcomes for successful and handler-failed tool calls. --- codex-rs/core/src/tools/lifecycle.rs | 98 ++++++++ codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/parallel.rs | 234 +++++++++++++++--- codex-rs/core/src/tools/registry.rs | 66 ++++- codex-rs/core/src/tools/registry_tests.rs | 194 +++++++++++++++ codex-rs/core/src/tools/router.rs | 53 +++- .../ext/extension-api/src/contributors.rs | 23 ++ .../src/contributors/tool_lifecycle.rs | 82 ++++++ codex-rs/ext/extension-api/src/lib.rs | 6 + codex-rs/ext/extension-api/src/registry.rs | 15 ++ codex-rs/ext/goal/src/extension.rs | 39 +++ 11 files changed, 777 insertions(+), 34 deletions(-) create mode 100644 codex-rs/core/src/tools/lifecycle.rs create mode 100644 codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs diff --git a/codex-rs/core/src/tools/lifecycle.rs b/codex-rs/core/src/tools/lifecycle.rs new file mode 100644 index 0000000000..ad8b492cce --- /dev/null +++ b/codex-rs/core/src/tools/lifecycle.rs @@ -0,0 +1,98 @@ +use codex_extension_api::ToolCallOutcome; +use codex_extension_api::ToolCallSource as ExtensionToolCallSource; +use codex_extension_api::ToolFinishInput; +use codex_extension_api::ToolStartInput; +use codex_tools::ToolName; + +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use crate::tools::context::ToolCallSource; +use crate::tools::context::ToolInvocation; + +pub(crate) async fn notify_tool_start(invocation: &ToolInvocation) { + for contributor in invocation + .session + .services + .extensions + .tool_lifecycle_contributors() + { + contributor + .on_tool_start(ToolStartInput { + session_store: &invocation.session.services.session_extension_data, + thread_store: &invocation.session.services.thread_extension_data, + turn_store: invocation.turn.extension_data.as_ref(), + turn_id: invocation.turn.sub_id.as_str(), + call_id: invocation.call_id.as_str(), + tool_name: &invocation.tool_name, + source: extension_tool_call_source(invocation.source.clone()), + }) + .await; + } +} + +pub(crate) async fn notify_tool_finish(invocation: &ToolInvocation, outcome: ToolCallOutcome) { + notify_tool_finish_parts( + invocation.session.as_ref(), + invocation.turn.as_ref(), + invocation.call_id.as_str(), + &invocation.tool_name, + invocation.source.clone(), + outcome, + ) + .await; +} + +pub(crate) async fn notify_tool_aborted( + session: &Session, + turn: &TurnContext, + call_id: &str, + tool_name: &ToolName, + source: ToolCallSource, +) { + notify_tool_finish_parts( + session, + turn, + call_id, + tool_name, + source, + ToolCallOutcome::Aborted, + ) + .await; +} + +async fn notify_tool_finish_parts( + session: &Session, + turn: &TurnContext, + call_id: &str, + tool_name: &ToolName, + source: ToolCallSource, + outcome: ToolCallOutcome, +) { + for contributor in session.services.extensions.tool_lifecycle_contributors() { + contributor + .on_tool_finish(ToolFinishInput { + session_store: &session.services.session_extension_data, + thread_store: &session.services.thread_extension_data, + turn_store: turn.extension_data.as_ref(), + turn_id: turn.sub_id.as_str(), + call_id, + tool_name, + source: extension_tool_call_source(source.clone()), + outcome, + }) + .await; + } +} + +fn extension_tool_call_source(source: ToolCallSource) -> ExtensionToolCallSource { + match source { + ToolCallSource::Direct => ExtensionToolCallSource::Direct, + ToolCallSource::CodeMode { + cell_id, + runtime_tool_call_id, + } => ExtensionToolCallSource::CodeMode { + cell_id, + runtime_tool_call_id, + }, + } +} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index dd63116353..5b7a17f428 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod events; pub(crate) mod handlers; pub(crate) mod hook_names; pub(crate) mod hosted_spec; +pub(crate) mod lifecycle; pub(crate) mod network_approval; pub(crate) mod orchestrator; pub(crate) mod parallel; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 4c79e4b168..15954869e2 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,7 +1,10 @@ use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Instant; use tokio::sync::RwLock; +use tokio::task::JoinError; use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; @@ -15,6 +18,7 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::AbortedToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; +use crate::tools::lifecycle::notify_tool_aborted; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolCall; @@ -89,6 +93,12 @@ impl ToolCallRuntime { let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); let started = Instant::now(); + let abort_session = Arc::clone(&session); + let abort_source = source.clone(); + let abort_turn = Arc::clone(&turn); + let terminal_outcome_reached = Arc::new(AtomicBool::new(false)); + let dispatch_terminal_outcome_reached = Arc::clone(&terminal_outcome_reached); + let dispatch_call = call.clone(); let dispatch_span = trace_span!( "dispatch_tool_call_with_code_mode_result", @@ -97,47 +107,69 @@ impl ToolCallRuntime { call_id = call.call_id.as_str(), aborted = false, ); + let abort_dispatch_span = dispatch_span.clone(); - let handle: AbortOnDropHandle> = + let mut handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { - tokio::select! { - _ = cancellation_token.cancelled() => { - let secs = started.elapsed().as_secs_f32().max(0.1); - dispatch_span.record("aborted", true); - Ok(Self::aborted_response(&call, secs)) - }, - res = async { - let _guard = if supports_parallel { - Either::Left(lock.read().await) - } else { - Either::Right(lock.write().await) - }; + let _guard = if supports_parallel { + Either::Left(lock.read().await) + } else { + Either::Right(lock.write().await) + }; - router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - invocation_cancellation_token, - tracker, - call.clone(), - source, - ) - .instrument(dispatch_span.clone()) - .await - } => res, - } + router + .dispatch_tool_call_with_terminal_outcome( + session, + turn, + invocation_cancellation_token, + tracker, + dispatch_call, + source, + dispatch_terminal_outcome_reached, + ) + .instrument(dispatch_span.clone()) + .await })); async move { - handle.await.map_err(|err| { - FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) - })? + tokio::select! { + res = &mut handle => res.map_err(Self::tool_task_join_error)?, + _ = cancellation_token.cancelled() => { + if terminal_outcome_reached.load(Ordering::Acquire) || handle.is_finished() { + handle.await.map_err(Self::tool_task_join_error)? + } else { + let secs = started.elapsed().as_secs_f32().max(0.1); + abort_dispatch_span.record("aborted", true); + handle.abort(); + match handle.await { + Ok(result) => result, + Err(err) if err.is_cancelled() => { + let response = Self::aborted_response(&call, secs); + notify_tool_aborted( + abort_session.as_ref(), + abort_turn.as_ref(), + call.call_id.as_str(), + &call.tool_name, + abort_source, + ) + .await; + Ok(response) + } + Err(err) => Err(Self::tool_task_join_error(err)), + } + } + }, + } } .in_current_span() } } impl ToolCallRuntime { + fn tool_task_join_error(err: JoinError) -> FunctionCallError { + FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) + } + fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem { let message = err.to_string(); match call.payload { @@ -189,3 +221,147 @@ impl ToolCallRuntime { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use crate::tools::context::FunctionToolOutput; + use crate::tools::context::ToolInvocation; + use crate::tools::registry::CoreToolRuntime; + use crate::tools::registry::ToolExecutor; + use crate::tools::registry::ToolRegistry; + use crate::turn_diff_tracker::TurnDiffTracker; + use codex_extension_api::ToolCallOutcome; + use codex_protocol::models::FunctionCallOutputBody; + use codex_protocol::models::FunctionCallOutputPayload; + use pretty_assertions::assert_eq; + use tokio::sync::Notify; + use tokio::sync::oneshot; + + struct ImmediateHandler { + tool_name: codex_tools::ToolName, + } + + #[async_trait::async_trait] + impl ToolExecutor for ImmediateHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) + } + } + + impl CoreToolRuntime for ImmediateHandler {} + + struct BlockingFinishContributor { + records: Arc>>, + finish_started: std::sync::Mutex>>, + allow_finish: Arc, + } + + impl codex_extension_api::ToolLifecycleContributor for BlockingFinishContributor { + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let allow_finish = Arc::clone(&self.allow_finish); + let finish_started = self + .finish_started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let outcome = input.outcome; + Box::pin(async move { + if let Some(finish_started) = finish_started { + let _ = finish_started.send(()); + } + allow_finish.notified().await; + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(outcome); + }) + } + } + + #[tokio::test] + async fn cancellation_after_handler_finishes_preserves_completed_lifecycle() + -> anyhow::Result<()> { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let (finish_started_tx, finish_started_rx) = oneshot::channel(); + let allow_finish = Arc::new(Notify::new()); + let mut builder = + codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(BlockingFinishContributor { + records: Arc::clone(&records), + finish_started: std::sync::Mutex::new(Some(finish_started_tx)), + allow_finish: Arc::clone(&allow_finish), + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("test_tool"); + let handler = Arc::new(ImmediateHandler { + tool_name: tool_name.clone(), + }) as Arc; + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + tokio::time::timeout(Duration::from_secs(1), finish_started_rx) + .await + .expect("timed out waiting for lifecycle notification to start") + .expect("lifecycle notification should start"); + cancellation_token.cancel(); + tokio::time::sleep(Duration::from_millis(10)).await; + allow_finish.notify_waiters(); + + let response = tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for tool response") + .expect("tool response task should join")?; + let expected_response = ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text("ok".to_string()), + success: Some(true), + }, + }; + assert_eq!(expected_response, response); + + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(vec![ToolCallOutcome::Completed { success: true }], actual); + + Ok(()) + } +} diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 96b686a83a..363c3f2a01 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use crate::function_tool::FunctionCallError; @@ -18,9 +20,12 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::lifecycle::notify_tool_finish; +use crate::tools::lifecycle::notify_tool_start; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::util::error_or_panic; +use codex_extension_api::ToolCallOutcome; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tools::ToolName; @@ -298,13 +303,23 @@ impl ToolRegistry { Some(tool.supports_parallel_tool_calls()) } + #[allow(dead_code)] + pub(crate) async fn dispatch_any( + &self, + invocation: ToolInvocation, + ) -> Result { + self.dispatch_any_with_terminal_outcome(invocation, /*terminal_outcome_reached*/ None) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "tool dispatch must keep active-turn accounting atomic" )] - pub(crate) async fn dispatch_any( + pub(crate) async fn dispatch_any_with_terminal_outcome( &self, mut invocation: ToolInvocation, + terminal_outcome_reached: Option>, ) -> Result { let tool_name = invocation.tool_name.clone(); let tool_name_flat = flat_tool_name(&tool_name); @@ -389,6 +404,8 @@ impl ToolRegistry { return Err(err); } + notify_tool_start(&invocation).await; + if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { match run_pre_tool_use_hooks( &invocation.session, @@ -402,13 +419,33 @@ impl ToolRegistry { PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message); dispatch_trace.record_failed(&err); + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish(&invocation, ToolCallOutcome::Blocked).await; return Err(err); } PreToolUseHookResult::Continue { updated_input: Some(updated_input), - } => { - invocation = tool.with_updated_hook_input(invocation, updated_input)?; - } + } => match tool.with_updated_hook_input(invocation.clone(), updated_input) { + Ok(updated_invocation) => { + invocation = updated_invocation; + } + Err(err) => { + dispatch_trace.record_failed(&err); + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish( + &invocation, + ToolCallOutcome::Failed { + handler_executed: false, + }, + ) + .await; + return Err(err); + } + }, PreToolUseHookResult::Continue { updated_input: None, } => {} @@ -503,6 +540,27 @@ impl ToolRegistry { } } + let lifecycle_outcome = match &result { + Ok(_) => { + let guard = response_cell.lock().await; + match guard.as_ref() { + Some(result) => ToolCallOutcome::Completed { + success: result.result.success_for_logging(), + }, + None => ToolCallOutcome::Failed { + handler_executed: true, + }, + } + } + Err(_) => ToolCallOutcome::Failed { + handler_executed: true, + }, + }; + if let Some(terminal_outcome_reached) = &terminal_outcome_reached { + terminal_outcome_reached.store(true, Ordering::Release); + } + notify_tool_finish(&invocation, lifecycle_outcome).await; + if let Err(err) = invocation .session .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index defacf33c0..e3ecfc8f98 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -23,6 +23,97 @@ impl ToolExecutor for TestHandler { impl CoreToolRuntime for TestHandler {} +#[derive(Clone)] +enum LifecycleTestResult { + Ok { success: bool }, + Err, +} + +struct LifecycleTestHandler { + tool_name: codex_tools::ToolName, + result: LifecycleTestResult, +} + +#[async_trait::async_trait] +impl ToolExecutor for LifecycleTestHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + match self.result.clone() { + LifecycleTestResult::Ok { success } => Ok(Box::new( + crate::tools::context::FunctionToolOutput::from_text( + "ok".to_string(), + Some(success), + ), + )), + LifecycleTestResult::Err => Err(FunctionCallError::RespondToModel( + "handler failed".to_string(), + )), + } + } +} + +impl CoreToolRuntime for LifecycleTestHandler {} + +#[derive(Debug, PartialEq, Eq)] +enum RecordedToolLifecycle { + Start { + call_id: String, + tool_name: codex_tools::ToolName, + }, + Finish { + call_id: String, + tool_name: codex_tools::ToolName, + outcome: codex_extension_api::ToolCallOutcome, + }, +} + +struct ToolLifecycleRecorder { + records: Arc>>, +} + +impl codex_extension_api::ToolLifecycleContributor for ToolLifecycleRecorder { + fn on_tool_start<'a>( + &'a self, + input: codex_extension_api::ToolStartInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let record = RecordedToolLifecycle::Start { + call_id: input.call_id.to_string(), + tool_name: input.tool_name.clone(), + }; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(record); + }) + } + + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let record = RecordedToolLifecycle::Finish { + call_id: input.call_id.to_string(), + tool_name: input.tool_name.clone(), + outcome: input.outcome, + }; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(record); + }) + } +} + #[test] fn handler_looks_up_namespaced_aliases_explicitly() { let namespace = "mcp__codex_apps__gmail"; @@ -61,3 +152,106 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[tokio::test] +async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> { + let (mut session, turn) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(ToolLifecycleRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + + let ok_tool = codex_tools::ToolName::plain("ok_tool"); + let failing_tool = codex_tools::ToolName::plain("failing_tool"); + let ok_handler = Arc::new(LifecycleTestHandler { + tool_name: ok_tool.clone(), + result: LifecycleTestResult::Ok { success: false }, + }) as Arc; + let failing_handler = Arc::new(LifecycleTestHandler { + tool_name: failing_tool.clone(), + result: LifecycleTestResult::Err, + }) as Arc; + let registry = ToolRegistry::new(HashMap::from([ + (ok_tool.clone(), ok_handler), + (failing_tool.clone(), failing_handler), + ])); + let session = Arc::new(session); + let turn = Arc::new(turn); + + registry + .dispatch_any(test_invocation( + Arc::clone(&session), + Arc::clone(&turn), + "ok-call", + ok_tool.clone(), + )) + .await?; + let err = match registry + .dispatch_any(test_invocation( + Arc::clone(&session), + Arc::clone(&turn), + "failing-call", + failing_tool.clone(), + )) + .await + { + Ok(_) => panic!("failing handler should return an error"), + Err(err) => err, + }; + assert_eq!(err.to_string(), "handler failed"); + + let expected = vec![ + RecordedToolLifecycle::Start { + call_id: "ok-call".to_string(), + tool_name: ok_tool.clone(), + }, + RecordedToolLifecycle::Finish { + call_id: "ok-call".to_string(), + tool_name: ok_tool, + outcome: codex_extension_api::ToolCallOutcome::Completed { success: false }, + }, + RecordedToolLifecycle::Start { + call_id: "failing-call".to_string(), + tool_name: failing_tool.clone(), + }, + RecordedToolLifecycle::Finish { + call_id: "failing-call".to_string(), + tool_name: failing_tool, + outcome: codex_extension_api::ToolCallOutcome::Failed { + handler_executed: true, + }, + }, + ]; + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(expected, actual); + + Ok(()) +} + +fn test_invocation( + session: Arc, + turn: Arc, + call_id: &str, + tool_name: codex_tools::ToolName, +) -> ToolInvocation { + ToolInvocation { + session, + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new( + crate::turn_diff_tracker::TurnDiffTracker::new(), + )), + call_id: call_id.to_string(), + tool_name, + source: crate::tools::context::ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + } +} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 2477ba347c..a279ec88d9 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -19,6 +19,7 @@ use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tokio_util::sync::CancellationToken; use tracing::instrument; @@ -123,6 +124,7 @@ impl ToolRouter { } } + #[allow(dead_code)] #[instrument(level = "trace", skip_all, err)] pub async fn dispatch_tool_call_with_code_mode_result( &self, @@ -132,6 +134,53 @@ impl ToolRouter { tracker: SharedTurnDiffTracker, call: ToolCall, source: ToolCallSource, + ) -> Result { + self.dispatch_tool_call_with_code_mode_result_inner( + session, + turn, + cancellation_token, + tracker, + call, + source, + /*terminal_outcome_reached*/ None, + ) + .await + } + + #[instrument(level = "trace", skip_all, err)] + #[allow(clippy::too_many_arguments)] + pub(crate) async fn dispatch_tool_call_with_terminal_outcome( + &self, + session: Arc, + turn: Arc, + cancellation_token: CancellationToken, + tracker: SharedTurnDiffTracker, + call: ToolCall, + source: ToolCallSource, + terminal_outcome_reached: Arc, + ) -> Result { + self.dispatch_tool_call_with_code_mode_result_inner( + session, + turn, + cancellation_token, + tracker, + call, + source, + Some(terminal_outcome_reached), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn dispatch_tool_call_with_code_mode_result_inner( + &self, + session: Arc, + turn: Arc, + cancellation_token: CancellationToken, + tracker: SharedTurnDiffTracker, + call: ToolCall, + source: ToolCallSource, + terminal_outcome_reached: Option>, ) -> Result { let ToolCall { tool_name, @@ -150,7 +199,9 @@ impl ToolRouter { payload, }; - self.registry.dispatch_any(invocation).await + self.registry + .dispatch_any_with_terminal_outcome(invocation, terminal_outcome_reached) + .await } } diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 4aa28d1049..8968e08573 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -11,6 +11,7 @@ use crate::ExtensionData; mod prompt; mod thread_lifecycle; +mod tool_lifecycle; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -18,6 +19,11 @@ pub use prompt::PromptSlot; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; +pub use tool_lifecycle::ToolCallOutcome; +pub use tool_lifecycle::ToolCallSource; +pub use tool_lifecycle::ToolFinishInput; +pub use tool_lifecycle::ToolLifecycleFuture; +pub use tool_lifecycle::ToolStartInput; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -111,6 +117,23 @@ pub trait ToolContributor: Send + Sync { ) -> Vec>>; } +/// Contributor for host-owned tool lifecycle gates. +/// +/// Implementations should use these callbacks to observe tool execution without +/// inspecting or rewriting tool input/output. Use `ToolContributor` for owning a +/// tool implementation and hooks for policy that needs tool payloads. +pub trait ToolLifecycleContributor: Send + Sync { + /// Called once the host has accepted a tool call for execution. + fn on_tool_start<'a>(&'a self, _input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(std::future::ready(())) + } + + /// Called after the tool call returns, is blocked, fails, or is cancelled. + fn on_tool_finish<'a>(&'a self, _input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(std::future::ready(())) + } +} + /// Future returned by one claimed approval-review contribution. pub type ApprovalReviewFuture<'a> = std::pin::Pin + Send + 'a>>; diff --git a/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs new file mode 100644 index 0000000000..486bca2643 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs @@ -0,0 +1,82 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_tools::ToolName; + +use crate::ExtensionData; + +/// Future returned by one tool-lifecycle callback. +pub type ToolLifecycleFuture<'a> = Pin + Send + 'a>>; + +/// Host-visible source for a model tool call. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ToolCallSource { + /// The model invoked the tool directly. + Direct, + /// Code mode invoked the tool while executing a runtime cell. + CodeMode { + /// Runtime cell that issued the nested tool request. + cell_id: String, + /// Code-mode's per-cell tool invocation id. + runtime_tool_call_id: String, + }, +} + +/// Extension-facing outcome for a finished tool call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ToolCallOutcome { + /// The tool returned a normal output. + Completed { + /// The tool output's own success marker for telemetry/logging. + success: bool, + }, + /// The tool was blocked by host policy before the handler ran. + Blocked, + /// The tool did not produce a normal output. + Failed { + /// Whether the host reached the tool handler before the failure. + handler_executed: bool, + }, + /// The host cancelled the tool before normal completion. Cancellation can + /// win before the dispatch path accepts the call, so contributors should not + /// assume a matching start callback exists. + Aborted, +} + +/// Input supplied when the host starts executing one tool call. +pub struct ToolStartInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, + /// Current turn submission id. + pub turn_id: &'a str, + /// Model-visible tool call id. + pub call_id: &'a str, + /// Tool name as routed by the host. + pub tool_name: &'a ToolName, + /// Source that issued the tool call. + pub source: ToolCallSource, +} + +/// Input supplied when the host finishes executing one tool call. +pub struct ToolFinishInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, + /// Current turn submission id. + pub turn_id: &'a str, + /// Model-visible tool call id. + pub call_id: &'a str, + /// Tool name as routed by the host. + pub tool_name: &'a ToolName, + /// Source that issued the tool call. + pub source: ToolCallSource, + /// Host-observed result of the tool call. + pub outcome: ToolCallOutcome, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index fe33d42128..373f3735a4 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -28,7 +28,13 @@ pub use contributors::ThreadResumeInput; pub use contributors::ThreadStartInput; pub use contributors::ThreadStopInput; pub use contributors::TokenUsageContributor; +pub use contributors::ToolCallOutcome; +pub use contributors::ToolCallSource; pub use contributors::ToolContributor; +pub use contributors::ToolFinishInput; +pub use contributors::ToolLifecycleContributor; +pub use contributors::ToolLifecycleFuture; +pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; pub use contributors::TurnItemContributionFuture; pub use contributors::TurnItemContributor; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 41d0967126..4577ddc048 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -10,6 +10,7 @@ use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; +use crate::ToolLifecycleContributor; use crate::TurnItemContributor; use crate::TurnLifecycleContributor; @@ -22,6 +23,7 @@ pub struct ExtensionRegistryBuilder { token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, + tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, approval_review_contributors: Vec>, } @@ -37,6 +39,7 @@ impl Default for ExtensionRegistryBuilder { approval_review_contributors: Vec::new(), context_contributors: Vec::new(), tool_contributors: Vec::new(), + tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), } } @@ -99,6 +102,11 @@ impl ExtensionRegistryBuilder { self.tool_contributors.push(contributor); } + /// Registers one tool-lifecycle contributor. + pub fn tool_lifecycle_contributor(&mut self, contributor: Arc) { + self.tool_lifecycle_contributors.push(contributor); + } + /// Registers one ordered turn-item contributor. pub fn turn_item_contributor(&mut self, contributor: Arc) { self.turn_item_contributors.push(contributor); @@ -115,6 +123,7 @@ impl ExtensionRegistryBuilder { approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, tool_contributors: self.tool_contributors, + tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, } } @@ -129,6 +138,7 @@ pub struct ExtensionRegistry { token_usage_contributors: Vec>, context_contributors: Vec>, tool_contributors: Vec>, + tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, approval_review_contributors: Vec>, } @@ -182,6 +192,11 @@ impl ExtensionRegistry { &self.tool_contributors } + /// Returns the registered tool-lifecycle contributors. + pub fn tool_lifecycle_contributors(&self) -> &[Arc] { + &self.tool_lifecycle_contributors + } + /// Returns the registered ordered turn-item contributors. pub fn turn_item_contributors(&self) -> &[Arc] { &self.turn_item_contributors diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index a0ce7117f4..a8d4f5c289 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -9,7 +9,11 @@ use codex_extension_api::NoopExtensionEventSink; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::TokenUsageContributor; +use codex_extension_api::ToolCallOutcome; use codex_extension_api::ToolContributor; +use codex_extension_api::ToolFinishInput; +use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::ToolLifecycleFuture; use codex_extension_api::TurnAbortInput; use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnStartInput; @@ -21,6 +25,7 @@ use codex_protocol::protocol::TurnAbortReason; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; +use crate::spec::UPDATE_GOAL_TOOL_NAME; use crate::tool::CreateGoalRequest; use crate::tool::GoalToolExecutor; @@ -221,6 +226,25 @@ where } } +impl ToolLifecycleContributor for GoalExtension +where + C: Send + Sync + 'static, +{ + fn on_tool_finish<'a>(&'a self, input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(async move { + let _should_count_for_goal_progress = goal_enabled(input.thread_store) + && tool_attempt_counts_for_goal_progress(input.outcome) + && !(input.tool_name.namespace.is_none() + && input.tool_name.name == UPDATE_GOAL_TOOL_NAME); + + // TODO: commit active goal progress through host goal storage and emit + // ThreadGoalUpdated when the persisted goal changes. This replaces + // GoalRuntimeEvent::ToolCompleted once the goal extension owns runtime + // accounting. + }) + } +} + // TODO: app-server initiated goal set/clear operations need a contributor or // backend callback here. They currently happen outside thread/turn/token // lifecycle, but the goal extension must observe them to account before @@ -288,6 +312,7 @@ pub fn install_with_backend( registry.config_contributor(extension.clone()); registry.turn_lifecycle_contributor(extension.clone()); registry.token_usage_contributor(extension.clone()); + registry.tool_lifecycle_contributor(extension.clone()); registry.tool_contributor(extension); } @@ -300,3 +325,17 @@ fn goal_enabled(thread_store: &ExtensionData) -> bool { fn accounting_state(thread_store: &ExtensionData) -> Arc { thread_store.get_or_init::(GoalAccountingState::default) } + +fn tool_attempt_counts_for_goal_progress(outcome: ToolCallOutcome) -> bool { + match outcome { + ToolCallOutcome::Completed { .. } => true, + ToolCallOutcome::Failed { + handler_executed: true, + } => true, + ToolCallOutcome::Blocked + | ToolCallOutcome::Failed { + handler_executed: false, + } + | ToolCallOutcome::Aborted => false, + } +} From 732b12b1ef57f8db60db6cf8f1f343f088f08a3a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 18 May 2026 13:06:39 -0700 Subject: [PATCH 078/114] Reduce rust-ci-full Windows nextest timeout flakes (#23253) ## Why Recent `rust-ci-full` failures were dominated by transient Windows timeout clusters in process-heavy tests such as `suite::resume`, `suite::cli_stream`, `suite::auth_env`, `start_thread_uses_all_default_environments_from_codex_home`, and `connect_stdio_command_initializes_json_rpc_client_on_windows`. The goal here is to make those known flaky paths less likely to fail full CI without relaxing the global nextest timeout policy. ## What changed - Enable one global nextest retry with `retries = 1` so a single transient failure can recover. - Add a `windows_process_heavy` test group with `max-threads = 2` for the recurring Windows subprocess/session-heavy timeout families. - Add Windows-only slow-timeout overrides for that process-heavy group. - Add a narrower Windows-only timeout override for `start_thread_uses_all_default_environments_from_codex_home`, which still exceeded the broader Windows bucket in both Windows full-CI lanes. - Increase the `rust-ci-full` nextest job timeout from `45m` to `60m` so Windows ARM64 still has job-level headroom after retries and targeted per-test timeout increases. - Keep the global `slow-timeout` unchanged at `15s`. ## Validation Validated through `rust-ci-full` GitHub Actions reruns on this PR. Observed improvement on the tuned Windows lanes: - Windows x64 went from `5 timed out` to `0 timed out`. - Windows ARM64 went from `2 timed out` to `0 timed out`. - `start_thread_uses_all_default_environments_from_codex_home` recovered as a flaky pass on Windows ARM64 instead of timing out. The remaining failing tests in those runs were unrelated hard failures outside this nextest timeout tuning. --- .github/workflows/rust-ci-full.yml | 7 +++---- codex-rs/.config/nextest.toml | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 697d4b9c37..b3c25b3f03 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -524,10 +524,9 @@ jobs: tests: name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} runs-on: ${{ matrix.runs_on || matrix.runner }} - # Perhaps we can bring this back down to 30m once we finish the cutover - # from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main - # offender for exceeding the timeout. - timeout-minutes: 45 + # Windows ARM64 is the long pole here, and nextest retries plus targeted + # Windows timeout headroom need more than 45m to finish reliably. + timeout-minutes: 60 defaults: run: working-directory: codex-rs diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index cc86ee0874..399caae16a 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -1,6 +1,7 @@ [profile.default] -# Do not increase, fix your test instead +# Retry once so one transient failure does not fail full-CI outright. slow-timeout = { period = "15s", terminate-after = 2 } +retries = 1 [profile.default.junit] path = "junit.xml" @@ -17,6 +18,9 @@ max-threads = 1 [test-groups.windows_sandbox_legacy_sessions] max-threads = 1 +[test-groups.windows_process_heavy] +max-threads = 2 + [[profile.default.overrides]] # Do not add new tests here filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)' @@ -47,3 +51,18 @@ test-group = 'core_apply_patch_cli_integration' # Serialize them to avoid exhausting Windows session/global desktop resources in CI. filter = 'package(codex-windows-sandbox) & test(legacy_)' test-group = 'windows_sandbox_legacy_sessions' + +[[profile.default.overrides]] +# This Codex-home startup path still exceeded the broader Windows-heavy ceiling +# in both Windows full-CI lanes after contention was reduced. +platform = 'cfg(windows)' +filter = 'test(start_thread_uses_all_default_environments_from_codex_home)' +slow-timeout = { period = "1m", terminate-after = 2 } + +[[profile.default.overrides]] +# These Windows-heavy tests spawn subprocesses, session files, or JSON-RPC +# clients and have been the dominant source of 30s full-CI timeouts. +platform = 'cfg(windows)' +filter = 'test(suite::resume::) | test(suite::cli_stream::) | test(suite::auth_env::) | test(start_thread_uses_all_default_environments_from_codex_home) | test(connect_stdio_command_initializes_json_rpc_client_on_windows)' +test-group = 'windows_process_heavy' +slow-timeout = { period = "45s", terminate-after = 2 } From 1752f374a80b5c667efe1273bb99af9b3da27918 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 18 May 2026 13:39:02 -0700 Subject: [PATCH 079/114] Improve `codex remote-control` CLI UX (#22878) ## Description This PR makes `codex remote-control` behave like a foreground CLI command by default. Running it now starts remote control, waits for readiness, prints a clear status message with the machine name, and stays alive until Ctrl-C. Users who want daemon behavior can use `codex remote-control start`, and `codex remote-control stop` now prints concise human-readable output. `--json` remains available for scripts. Implementation-wise, this now verifies the real app-server state instead of just assuming startup worked. The CLI starts or connects to app-server, probes its control socket, calls the `remoteControl/enable` API, and waits for the remote-control status response/notification before printing success. For daemon mode, `codex remote-control start` also reports which managed app-server binary was used, including its path and best-effort `codex --version`, so failures are easier to diagnose. ## Examples Example output: ``` > codex remote-control Starting app-server with remote control enabled... This machine is available for remote control as com-97826. Press Ctrl-C to stop. ``` Error case using daemon (currently expected based on our publicly released CLI version): ``` > ./target/debug/codex remote-control start Starting app-server daemon with remote control enabled... Error: app server did not become ready on /Users/owen/.codex/app-server-control/app-server-control.sock Daemon used app-server: path: /Users/owen/.codex/packages/standalone/current/codex version: 0.130.0 Managed app-server stderr (/Users/owen/.codex/app-server-daemon/app-server.stderr.log): error: unexpected argument '--remote-control' found Usage: codex app-server [OPTIONS] [COMMAND] For more information, try '--help'. Caused by: 0: failed to connect to /Users/owen/.codex/app-server-control/app-server-control.sock 1: No such file or directory (os error 2) ``` ## What changed - `codex remote-control` now runs remote control in the foreground and prints a Ctrl-C stop hint. - `codex remote-control start` starts the daemon and waits for remote control readiness before reporting success. - `codex remote-control stop` reports stopped/not-running status in plain language. - Startup failures now include recent managed app-server stderr to make daemon issues easier to diagnose. - Added coverage for CLI output, readiness waiting, foreground shutdown, and stderr log tailing. --- codex-rs/app-server-daemon/src/backend/mod.rs | 13 + codex-rs/app-server-daemon/src/backend/pid.rs | 104 ++- .../src/backend/pid_tests.rs | 24 + codex-rs/app-server-daemon/src/client.rs | 140 ++-- codex-rs/app-server-daemon/src/lib.rs | 270 +++++-- .../src/remote_control_client.rs | 459 ++++++++++++ codex-rs/app-server/src/lib.rs | 5 +- codex-rs/cli/src/main.rs | 59 +- codex-rs/cli/src/remote_control_cmd.rs | 695 ++++++++++++++++++ 9 files changed, 1611 insertions(+), 158 deletions(-) create mode 100644 codex-rs/app-server-daemon/src/remote_control_client.rs create mode 100644 codex-rs/cli/src/remote_control_cmd.rs diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs index 5e92dd5261..d40df07a0e 100644 --- a/codex-rs/app-server-daemon/src/backend/mod.rs +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -1,5 +1,6 @@ mod pid; +use std::path::Path; use std::path::PathBuf; use serde::Serialize; @@ -31,3 +32,15 @@ pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend { pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend { PidBackend::new_update_loop(paths.codex_bin, paths.update_pid_file) } + +pub(crate) async fn append_stderr_log_tail_context(pid_file: &Path, context: &mut String) { + match pid::read_stderr_log_tail(pid_file).await { + Ok(Some(tail)) => tail.append_to_context(context), + Ok(None) => {} + Err(err) => { + context.push_str(&format!( + "\n\nFailed to read managed app-server stderr log: {err:#}" + )); + } + } +} diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 7f8aa1660d..f5f4fc5b66 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -1,3 +1,4 @@ +use std::io::SeekFrom; use std::path::Path; use std::path::PathBuf; #[cfg(unix)] @@ -10,6 +11,8 @@ use anyhow::bail; use serde::Deserialize; use serde::Serialize; use tokio::fs; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncSeekExt; #[cfg(unix)] use tokio::process::Command; use tokio::time::sleep; @@ -18,6 +21,7 @@ const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); const STOP_GRACE_PERIOD: Duration = Duration::from_secs(60); const STOP_TIMEOUT: Duration = Duration::from_secs(70); const START_TIMEOUT: Duration = Duration::from_secs(10); +const STDERR_LOG_TAIL_BYTES: u64 = 4096; #[derive(Debug)] #[cfg_attr(not(unix), allow(dead_code))] @@ -35,6 +39,25 @@ struct PidRecord { process_start_time: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PidLogTail { + pub(crate) path: PathBuf, + pub(crate) contents: String, +} + +impl PidLogTail { + pub(crate) fn append_to_context(&self, context: &mut String) { + context.push_str(&format!( + "\n\nManaged app-server stderr ({}):", + self.path.display() + )); + for line in self.contents.lines() { + context.push_str("\n "); + context.push_str(line); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum PidFileState { Missing, @@ -129,11 +152,18 @@ impl PidBackend { } }; let mut command = Command::new(&self.codex_bin); + let stderr_log = match self.open_stderr_log().await { + Ok(stderr_log) => stderr_log, + Err(err) => { + let _ = fs::remove_file(&self.pid_file).await; + return Err(err); + } + }; command .args(self.command_args()) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::from(stderr_log.into_std().await)); #[cfg(unix)] { @@ -169,8 +199,11 @@ impl PidBackend { }, Err(err) => { let _ = self.terminate_process(pid); + let mut context = + format!("failed to record pid-managed app-server process {pid} startup"); + super::append_stderr_log_tail_context(&self.pid_file, &mut context).await; let _ = fs::remove_file(&self.pid_file).await; - return Err(err); + return Err(err).context(context); } }; let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?; @@ -344,6 +377,23 @@ impl PidBackend { Ok(reservation_lock) } + #[cfg(unix)] + async fn open_stderr_log(&self) -> Result { + let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file); + fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&stderr_log_file) + .await + .with_context(|| { + format!( + "failed to open stderr log for pid-managed app server {}", + stderr_log_file.display() + ) + }) + } + #[cfg(unix)] fn command_args(&self) -> Vec<&'static str> { match self.command_kind { @@ -376,6 +426,56 @@ impl PidBackend { } } +pub(crate) async fn read_stderr_log_tail(pid_file: &Path) -> Result> { + let path = stderr_log_file_for_pid_file(pid_file); + let Some(contents) = read_log_tail(&path, STDERR_LOG_TAIL_BYTES).await? else { + return Ok(None); + }; + Ok(Some(PidLogTail { path, contents })) +} + +fn stderr_log_file_for_pid_file(pid_file: &Path) -> PathBuf { + pid_file.with_extension("stderr.log") +} + +async fn read_log_tail(path: &Path, byte_limit: u64) -> Result> { + let mut file = match fs::File::open(path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err) + .with_context(|| format!("failed to open stderr log {}", path.display())); + } + }; + let len = file + .metadata() + .await + .with_context(|| format!("failed to inspect stderr log {}", path.display()))? + .len(); + if len == 0 { + return Ok(None); + } + + let start = len.saturating_sub(byte_limit); + file.seek(SeekFrom::Start(start)) + .await + .with_context(|| format!("failed to seek stderr log {}", path.display()))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .await + .with_context(|| format!("failed to read stderr log {}", path.display()))?; + if start > 0 + && let Some(newline_index) = bytes.iter().position(|byte| *byte == b'\n') + { + bytes.drain(..=newline_index); + } + let contents = String::from_utf8_lossy(&bytes).trim_end().to_string(); + if contents.is_empty() { + return Ok(None); + } + Ok(Some(contents)) +} + #[cfg(unix)] fn process_exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index 305c2a36a3..4c3a0e4414 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -6,7 +6,10 @@ use tempfile::TempDir; use super::PidBackend; use super::PidCommandKind; use super::PidFileState; +use super::PidLogTail; use super::PidRecord; +use super::read_stderr_log_tail; +use super::stderr_log_file_for_pid_file; use super::try_lock_file; #[tokio::test] @@ -170,3 +173,24 @@ fn app_server_remote_control_uses_runtime_flag() { vec!["app-server", "--remote-control", "--listen", "unix://"] ); } + +#[tokio::test] +async fn read_stderr_log_tail_returns_recent_complete_lines() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + let log_file = stderr_log_file_for_pid_file(&pid_file); + let contents = format!("{}\nrecent error\nusage", "x".repeat(4100)); + tokio::fs::write(&log_file, contents) + .await + .expect("write stderr log"); + + assert_eq!( + read_stderr_log_tail(&pid_file) + .await + .expect("read stderr log"), + Some(PidLogTail { + path: log_file, + contents: "recent error\nusage".to_string(), + }) + ); +} diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs index 44fccda394..8c771ba1f2 100644 --- a/codex-rs/app-server-daemon/src/client.rs +++ b/codex-rs/app-server-daemon/src/client.rs @@ -5,6 +5,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCMessage; @@ -14,12 +15,16 @@ use codex_app_server_protocol::RequestId; use codex_uds::UnixStream; use futures::SinkExt; use futures::StreamExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; -const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const CONTROL_SOCKET_RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); const CLIENT_NAME: &str = "codex_app_server_daemon"; +const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProbeInfo { @@ -27,7 +32,7 @@ pub(crate) struct ProbeInfo { } pub(crate) async fn probe(socket_path: &Path) -> Result { - timeout(PROBE_TIMEOUT, probe_inner(socket_path)) + timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, probe_inner(socket_path)) .await .with_context(|| { format!( @@ -38,54 +43,14 @@ pub(crate) async fn probe(socket_path: &Path) -> Result { } async fn probe_inner(socket_path: &Path) -> Result { - let stream = UnixStream::connect(socket_path) - .await - .with_context(|| format!("failed to connect to {}", socket_path.display()))?; - let (mut websocket, _response) = client_async("ws://localhost/", stream) - .await - .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; - - let initialize = JSONRPCMessage::Request(JSONRPCRequest { - id: RequestId::Integer(1), - method: "initialize".to_string(), - params: Some(serde_json::to_value(InitializeParams { - client_info: ClientInfo { - name: CLIENT_NAME.to_string(), - title: Some("Codex App Server Daemon".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: None, - })?), - trace: None, - }); - websocket - .send(Message::Text(serde_json::to_string(&initialize)?.into())) - .await - .context("failed to send initialize request")?; - - let response = loop { - let frame = websocket - .next() - .await - .ok_or_else(|| anyhow!("app-server closed before initialize response"))??; - let Message::Text(payload) = frame else { - continue; - }; - let message = serde_json::from_str::(&payload)?; - if let JSONRPCMessage::Response(response) = message - && response.id == RequestId::Integer(1) - { - break response; - } - }; - let initialize_response = serde_json::from_value::(response.result)?; + let mut websocket = connect(socket_path).await?; + let initialize_response = initialize(&mut websocket, /*experimental_api*/ false).await?; let initialized = JSONRPCMessage::Notification(JSONRPCNotification { method: "initialized".to_string(), params: None, }); - websocket - .send(Message::Text(serde_json::to_string(&initialized)?.into())) + send_message(&mut websocket, &initialized) .await .context("failed to send initialized notification")?; websocket.close(None).await.ok(); @@ -95,6 +60,91 @@ async fn probe_inner(socket_path: &Path) -> Result { }) } +pub(crate) async fn connect(socket_path: &Path) -> Result> { + let stream = UnixStream::connect(socket_path) + .await + .with_context(|| format!("failed to connect to {}", socket_path.display()))?; + let (websocket, _response) = client_async("ws://localhost/", stream) + .await + .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; + Ok(websocket) +} + +pub(crate) async fn initialize( + websocket: &mut WebSocketStream, + experimental_api: bool, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let initialize = JSONRPCMessage::Request(JSONRPCRequest { + id: INITIALIZE_REQUEST_ID, + method: "initialize".to_string(), + params: Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: CLIENT_NAME.to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: if experimental_api { + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }) + } else { + None + }, + })?), + trace: None, + }); + send_message(websocket, &initialize) + .await + .context("failed to send initialize request")?; + + let response = loop { + let message = timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, read_message(websocket)) + .await + .context("timed out waiting for initialize response")??; + if let JSONRPCMessage::Response(response) = message + && response.id == INITIALIZE_REQUEST_ID + { + break response; + } + }; + serde_json::from_value::(response.result) + .context("failed to parse initialize response") +} + +pub(crate) async fn send_message( + websocket: &mut WebSocketStream, + message: &JSONRPCMessage, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + websocket + .send(Message::Text(serde_json::to_string(message)?.into())) + .await?; + Ok(()) +} + +pub(crate) async fn read_message(websocket: &mut WebSocketStream) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let frame = websocket + .next() + .await + .ok_or_else(|| anyhow!("app-server closed the control socket"))??; + let Message::Text(payload) = frame else { + continue; + }; + return serde_json::from_str::(&payload) + .context("failed to parse app-server JSON-RPC message"); + } +} + fn parse_version_from_user_agent(user_agent: &str) -> Result { let (_originator, rest) = user_agent .split_once('/') diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 4ab223051c..62864fd5b7 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -1,6 +1,7 @@ mod backend; mod client; mod managed_install; +mod remote_control_client; mod settings; mod update_loop; @@ -13,6 +14,7 @@ use anyhow::Result; use anyhow::anyhow; pub use backend::BackendKind; use backend::BackendPaths; +use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_transport::app_server_control_socket_path; use codex_utils_home_dir::find_codex_home; use managed_install::managed_codex_bin; @@ -58,6 +60,8 @@ pub struct LifecycleOutput { pub backend: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pid: Option, + pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub cli_version: Option, @@ -84,6 +88,7 @@ pub struct BootstrapOutput { pub auto_update_enabled: bool, pub remote_control_enabled: bool, pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, pub cli_version: String, pub app_server_version: String, @@ -96,6 +101,20 @@ pub enum RemoteControlStartOutput { Start(LifecycleOutput), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyStatus { + pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub environment_id: Option, + pub timed_out: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyOutput { + pub daemon: RemoteControlStartOutput, + pub remote_control: RemoteControlReadyStatus, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteControlMode { Enabled, @@ -179,6 +198,27 @@ pub async fn ensure_remote_control_started() -> Result .await } +pub async fn ensure_remote_control_ready() -> Result { + ensure_supported_platform()?; + Daemon::from_environment()? + .ensure_remote_control_ready() + .await +} + +pub async fn enable_remote_control_on_socket( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + ensure_supported_platform()?; + remote_control_client::enable_remote_control_with_connect_retry( + socket_path, + connect_timeout, + connect_retry_delay, + ) + .await +} + pub async fn set_remote_control(mode: RemoteControlMode) -> Result { ensure_supported_platform()?; Daemon::from_environment()?.set_remote_control(mode).await @@ -248,33 +288,39 @@ impl Daemon { async fn start(&self) -> Result { let settings = self.load_settings().await?; if let Ok(info) = client::probe(&self.socket_path).await { - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } if self.running_backend_instance(&settings).await?.is_some() { let info = self.wait_until_ready().await?; - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - Some(BackendKind::Pid), - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + Some(BackendKind::Pid), + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } self.ensure_managed_codex_bin()?; let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Started, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Started, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } async fn restart(&self) -> Result { @@ -294,12 +340,14 @@ impl Daemon { let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Restarted, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Restarted, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } #[cfg(unix)] @@ -352,12 +400,14 @@ impl Daemon { let settings = self.load_settings().await?; if let Some(backend) = self.running_backend_instance(&settings).await? { backend.stop().await?; - return Ok(self.output( - LifecycleStatus::Stopped, - Some(BackendKind::Pid), - /*pid*/ None, - /*app_server_version*/ None, - )); + return Ok(self + .output( + LifecycleStatus::Stopped, + Some(BackendKind::Pid), + /*pid*/ None, + /*app_server_version*/ None, + ) + .await); } if client::probe(&self.socket_path).await.is_ok() { @@ -366,23 +416,27 @@ impl Daemon { )); } - Ok(self.output( - LifecycleStatus::NotRunning, - /*backend*/ None, - /*pid*/ None, - /*app_server_version*/ None, - )) + Ok(self + .output( + LifecycleStatus::NotRunning, + /*backend*/ None, + /*pid*/ None, + /*app_server_version*/ None, + ) + .await) } async fn version(&self) -> Result { let settings = self.load_settings().await?; let info = client::probe(&self.socket_path).await?; - Ok(self.output( - LifecycleStatus::Running, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Running, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await) } async fn wait_until_ready(&self) -> Result { @@ -395,17 +449,34 @@ impl Daemon { sleep(START_POLL_INTERVAL).await; } Err(err) => { - return Err(err).with_context(|| { - format!( - "app server did not become ready on {}", - self.socket_path.display() - ) - }); + let context = self.app_server_not_ready_context().await; + return Err(err).context(context); } } } } + async fn app_server_not_ready_context(&self) -> String { + let mut context = format!( + "app server did not become ready on {}", + self.socket_path.display() + ); + self.append_daemon_app_server_context(&mut context).await; + backend::append_stderr_log_tail_context(&self.pid_file, &mut context).await; + context + } + + async fn append_daemon_app_server_context(&self, context: &mut String) { + let managed_codex_version = self + .managed_codex_version_best_effort() + .await + .unwrap_or_else(|| "unknown".to_string()); + context.push_str(&format!( + "\n\nDaemon used app-server:\n path: {}\n version: {managed_codex_version}", + self.managed_codex_bin.display() + )); + } + async fn bootstrap(&self, options: BootstrapOptions) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.bootstrap_locked(options).await @@ -430,6 +501,16 @@ impl Daemon { Ok(RemoteControlStartOutput::Bootstrap(output)) } + async fn ensure_remote_control_ready(&self) -> Result { + let daemon = self.ensure_remote_control_started().await?; + let remote_control = + remote_control_client::enable_remote_control(&self.socket_path).await?; + Ok(RemoteControlReadyOutput { + daemon, + remote_control, + }) + } + async fn set_remote_control(&self, mode: RemoteControlMode) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.set_remote_control_locked(mode).await @@ -512,12 +593,14 @@ impl Daemon { updater.start().await?; let info = self.wait_until_ready().await?; + let managed_codex_version = self.managed_codex_version_best_effort().await; Ok(BootstrapOutput { status: BootstrapStatus::Bootstrapped, backend: BackendKind::Pid, auto_update_enabled: true, remote_control_enabled: settings.remote_control_enabled, managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), app_server_version: info.app_server_version, @@ -577,6 +660,16 @@ impl Daemon { )) } + #[cfg(unix)] + async fn managed_codex_version_best_effort(&self) -> Option { + managed_codex_version(&self.managed_codex_bin).await.ok() + } + + #[cfg(not(unix))] + async fn managed_codex_version_best_effort(&self) -> Option { + None + } + fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths { self.backend_paths_with_bin(settings, &self.managed_codex_bin) } @@ -636,17 +729,20 @@ impl Daemon { }) } - fn output( + async fn output( &self, status: LifecycleStatus, backend: Option, pid: Option, app_server_version: Option, ) -> LifecycleOutput { + let managed_codex_version = self.managed_codex_version_best_effort().await; LifecycleOutput { status, backend, pid, + managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), app_server_version, @@ -735,10 +831,12 @@ fn try_lock_file(_file: &tokio::fs::File) -> Result { #[cfg(all(test, unix))] mod tests { use pretty_assertions::assert_eq; + use tempfile::TempDir; use super::BackendKind; use super::BootstrapOutput; use super::BootstrapStatus; + use super::Daemon; use super::LifecycleOutput; use super::LifecycleStatus; use super::RemoteControlStartOutput; @@ -751,22 +849,6 @@ mod tests { use super::should_reexec_updater; use crate::client::ProbeInfo; - #[test] - fn lifecycle_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&LifecycleStatus::AlreadyRunning).expect("serialize"), - "\"alreadyRunning\"" - ); - } - - #[test] - fn bootstrap_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&BootstrapStatus::Bootstrapped).expect("serialize"), - "\"bootstrapped\"" - ); - } - #[test] fn remote_control_status_uses_camel_case_json() { assert_eq!( @@ -847,12 +929,26 @@ mod tests { status: LifecycleStatus::AlreadyRunning, backend: Some(BackendKind::Pid), pid: None, + managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: Some("1.2.3".to_string()), app_server_version: Some("1.2.4".to_string()), }; let output = RemoteControlStartOutput::Start(lifecycle_output.clone()); + assert_eq!( + serde_json::to_value(&lifecycle_output).expect("serialize"), + serde_json::json!({ + "status": "alreadyRunning", + "backend": "pid", + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(lifecycle_output).expect("serialize") @@ -864,15 +960,59 @@ mod tests { auto_update_enabled: true, remote_control_enabled: true, managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: "1.2.3".to_string(), app_server_version: "1.2.4".to_string(), }; let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone()); + assert_eq!( + serde_json::to_value(&bootstrap_output).expect("serialize"), + serde_json::json!({ + "status": "bootstrapped", + "backend": "pid", + "autoUpdateEnabled": true, + "remoteControlEnabled": true, + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(bootstrap_output).expect("serialize") ); } + + #[tokio::test] + async fn not_ready_context_reports_daemon_app_server_before_stderr() { + let temp_dir = TempDir::new().expect("temp dir"); + let daemon = Daemon { + socket_path: temp_dir.path().join("app-server-control.sock"), + pid_file: temp_dir.path().join("app-server.pid"), + update_pid_file: temp_dir.path().join("app-server-updater.pid"), + operation_lock_file: temp_dir.path().join("daemon.lock"), + settings_file: temp_dir.path().join("settings.json"), + managed_codex_bin: temp_dir.path().join("missing-codex"), + }; + let stderr_log = daemon.pid_file.with_extension("stderr.log"); + tokio::fs::write(&stderr_log, "unexpected argument") + .await + .expect("write stderr log"); + + assert_eq!( + daemon.app_server_not_ready_context().await, + format!( + "app server did not become ready on {}\n\n\ + Daemon used app-server:\n path: {}\n version: unknown\n\n\ + Managed app-server stderr ({}):\n unexpected argument", + daemon.socket_path.display(), + daemon.managed_codex_bin.display(), + stderr_log.display() + ) + ); + } } diff --git a/codex-rs/app-server-daemon/src/remote_control_client.rs b/codex-rs/app-server-daemon/src/remote_control_client.rs new file mode 100644 index 0000000000..cc32e50dde --- /dev/null +++ b/codex-rs/app-server-daemon/src/remote_control_client.rs @@ -0,0 +1,459 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_app_server_protocol::RequestId; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; + +use crate::RemoteControlReadyStatus; +use crate::client; + +const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_CONTROL_ENABLE_REQUEST_ID: RequestId = RequestId::Integer(2); + +pub(crate) async fn enable_remote_control(socket_path: &Path) -> Result { + let mut websocket = client::connect(socket_path).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +pub(crate) async fn enable_remote_control_with_connect_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + let mut websocket = + connect_with_retry(socket_path, connect_timeout, connect_retry_delay).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +async fn enable_remote_control_with_timeout( + websocket: &mut WebSocketStream, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + client::initialize(websocket, /*experimental_api*/ true).await?; + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + client::send_message(websocket, &initialized) + .await + .context("failed to send initialized notification")?; + + let enable = JSONRPCMessage::Request(JSONRPCRequest { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + method: "remoteControl/enable".to_string(), + params: None, + trace: None, + }); + client::send_message(websocket, &enable) + .await + .context("failed to send remoteControl/enable request")?; + + let mut latest = read_enable_response(websocket).await?; + if latest.status == RemoteControlConnectionStatus::Connecting { + latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?; + } + websocket.close(None).await.ok(); + Ok(latest) +} + +async fn connect_with_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result> { + let deadline = Instant::now() + connect_timeout; + loop { + match client::connect(socket_path).await { + Ok(websocket) => return Ok(websocket), + Err(_) if Instant::now() < deadline => { + sleep(connect_retry_delay).await; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "app server did not become ready on {}", + socket_path.display() + ) + }); + } + } + } +} + +async fn read_enable_response( + websocket: &mut WebSocketStream, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let message = timeout( + client::CONTROL_SOCKET_RESPONSE_TIMEOUT, + client::read_message(websocket), + ) + .await + .context("timed out waiting for remoteControl/enable response")??; + match message { + JSONRPCMessage::Response(response) + if response.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => + { + let response = + serde_json::from_value::(response.result) + .context("failed to parse remoteControl/enable response")?; + return Ok(RemoteControlReadyStatus::from(response)); + } + JSONRPCMessage::Error(err) if err.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => { + return Err(anyhow!( + "remoteControl/enable failed: {}", + err.error.message + )); + } + JSONRPCMessage::Notification(notification) + if remote_control_status_notification(¬ification).is_some() => + { + continue; + } + _ => {} + } + } +} + +async fn wait_for_remote_control_status( + websocket: &mut WebSocketStream, + mut latest: RemoteControlReadyStatus, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let deadline = tokio::time::Instant::now() + ready_timeout; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = match timeout(remaining, client::read_message(websocket)).await { + Ok(Ok(message)) => message, + Ok(Err(err)) => return Err(err), + Err(_) => { + latest.timed_out = true; + return Ok(latest); + } + }; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + let Some(status) = remote_control_status_notification(¬ification) else { + continue; + }; + latest = RemoteControlReadyStatus::from(status); + if latest.status != RemoteControlConnectionStatus::Connecting { + return Ok(latest); + } + } + latest.timed_out = true; + Ok(latest) +} + +fn remote_control_status_notification( + notification: &JSONRPCNotification, +) -> Option { + if notification.method != "remoteControl/status/changed" { + return None; + } + let params = notification.params.clone()?; + serde_json::from_value(params).ok() +} + +impl From for RemoteControlReadyStatus { + fn from(response: RemoteControlEnableResponse) -> Self { + let RemoteControlEnableResponse { + status, + server_name, + installation_id: _, + environment_id, + } = response; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +impl From for RemoteControlReadyStatus { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + server_name, + installation_id: _, + environment_id, + } = notification; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use anyhow::Result; + use codex_app_server_protocol::JSONRPCResponse; + use codex_uds::UnixListener; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use tokio_tungstenite::accept_async; + + use super::*; + + const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_SERVER_NAME: &str = "owen-mbp"; + const TEST_CODEX_HOME: &str = "/tmp/codex-home"; + + #[tokio::test] + async fn enable_remote_control_uses_connected_enable_response_without_later_notification() + -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_waits_for_connected_notification() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + ready_timeout: Duration::from_secs(1), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_reports_connecting_after_timeout() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connecting, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: true, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_returns_errored_enable_response() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Errored, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Errored, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: false, + } + ); + Ok(()) + } + + struct EnableScenario { + initial_notification: Option, + enable_response: RemoteControlStatusChangedNotification, + after_enable_notification: Option, + ready_timeout: Duration, + } + + async fn run_enable_remote_control_scenario( + scenario: EnableScenario, + ) -> Result { + let dir = TempDir::new()?; + let socket_path = dir.path().join("app-server.sock"); + let listener = UnixListener::bind(&socket_path).await?; + let ready_timeout = scenario.ready_timeout; + let server_task = tokio::spawn(serve_enable_remote_control_scenario(listener, scenario)); + + let mut websocket = client::connect(&socket_path).await?; + let status = enable_remote_control_with_timeout(&mut websocket, ready_timeout).await?; + server_task.await??; + Ok(status) + } + + async fn serve_enable_remote_control_scenario( + mut listener: UnixListener, + scenario: EnableScenario, + ) -> Result<()> { + let stream = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + + let initialize = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(initialize) = initialize else { + panic!("expected initialize request"); + }; + assert_eq!(initialize.id, INITIALIZE_REQUEST_ID); + assert_eq!(initialize.method, "initialize"); + let Some(initialize_params) = initialize.params else { + panic!("expected initialize params"); + }; + assert_eq!( + initialize_params["capabilities"]["experimentalApi"], + serde_json::Value::Bool(true) + ); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: INITIALIZE_REQUEST_ID, + result: serde_json::json!({ + "userAgent": "codex_app_server/1.2.3", + "codexHome": TEST_CODEX_HOME, + "platformFamily": "unix", + "platformOs": "macos", + }), + }), + ) + .await?; + + let initialized = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Notification(initialized) = initialized else { + panic!("expected initialized notification"); + }; + assert_eq!(initialized.method, "initialized"); + + if let Some(status) = scenario.initial_notification { + send_remote_control_status(&mut websocket, status).await?; + } + + let enable = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(enable) = enable else { + panic!("expected remoteControl/enable request"); + }; + assert_eq!(enable.id, REMOTE_CONTROL_ENABLE_REQUEST_ID); + assert_eq!(enable.method, "remoteControl/enable"); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + result: serde_json::to_value(RemoteControlEnableResponse::from( + scenario.enable_response, + ))?, + }), + ) + .await?; + + if let Some(status) = scenario.after_enable_notification { + send_remote_control_status(&mut websocket, status).await?; + } else { + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Ok(()) + } + + async fn send_remote_control_status( + websocket: &mut WebSocketStream, + status: RemoteControlStatusChangedNotification, + ) -> Result<()> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + client::send_message( + websocket, + &JSONRPCMessage::Notification(JSONRPCNotification { + method: "remoteControl/status/changed".to_string(), + params: Some(serde_json::to_value(status)?), + }), + ) + .await + } + + fn remote_control_status( + status: RemoteControlConnectionStatus, + environment_id: Option<&str>, + ) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status, + server_name: TEST_SERVER_NAME.to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: environment_id.map(str::to_string), + } + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4482d326f6..142df216b5 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -400,6 +400,7 @@ pub enum PluginStartupTasks { pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, pub remote_control_enabled: bool, + pub install_shutdown_signal_handler: bool, } impl Default for AppServerRuntimeOptions { @@ -407,6 +408,7 @@ impl Default for AppServerRuntimeOptions { Self { plugin_startup_tasks: PluginStartupTasks::Start, remote_control_enabled: false, + install_shutdown_signal_handler: true, } } } @@ -645,7 +647,8 @@ pub async fn run_main_with_transport_options( let single_client_mode = matches!(&transport, AppServerTransport::Stdio); let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; + let graceful_signal_restart_enabled = + runtime_options.install_shutdown_signal_handler && !single_client_mode; let mut app_server_client_name_rx = None; match &transport { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 4b0d2330a6..1b71ab15ae 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -52,6 +52,7 @@ mod doctor; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; +mod remote_control_cmd; mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -59,6 +60,7 @@ mod wsl_paths; use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; +use crate::remote_control_cmd::RemoteControlCommand; use doctor::DoctorCommand; use state_db_recovery as local_state_db; @@ -565,21 +567,6 @@ struct AppServerBootstrapCommand { remote_control: bool, } -#[derive(Debug, Args)] -struct RemoteControlCommand { - #[command(subcommand)] - subcommand: Option, -} - -#[derive(Debug, Clone, Copy, clap::Subcommand)] -enum RemoteControlSubcommand { - /// Start the app-server daemon with remote control enabled. - Start, - - /// Stop the app-server daemon. - Stop, -} - #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -1070,24 +1057,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::RemoteControl(remote_control_cli)) => { - let subcommand_name = remote_control_subcommand_name(&remote_control_cli); + let subcommand_name = remote_control_cli.subcommand_name(); reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), subcommand_name, )?; - match remote_control_cli - .subcommand - .unwrap_or(RemoteControlSubcommand::Start) - { - RemoteControlSubcommand::Start => { - let output = codex_app_server_daemon::ensure_remote_control_started().await?; - println!("{}", serde_json::to_string(&output)?); - } - RemoteControlSubcommand::Stop => { - print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; - } - } + remote_control_cmd::run( + remote_control_cli, + arg0_paths.clone(), + root_config_overrides, + ) + .await?; } #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { @@ -1892,9 +1873,7 @@ fn unsupported_subcommand_name_for_strict_config( Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) } - Some(Subcommand::RemoteControl(remote_control)) => { - Some(remote_control_subcommand_name(remote_control)) - } + Some(Subcommand::RemoteControl(remote_control)) => Some(remote_control.subcommand_name()), Some(Subcommand::Mcp(_)) => Some("mcp"), Some(Subcommand::Plugin(_)) => Some("plugin"), #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -1947,14 +1926,6 @@ fn reject_remote_mode_for_app_server_subcommand( reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) } -fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str { - match command.subcommand { - None => "remote-control", - Some(RemoteControlSubcommand::Start) => "remote-control start", - Some(RemoteControlSubcommand::Stop) => "remote-control stop", - } -} - fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { match subcommand { None => "app-server", @@ -2974,12 +2945,10 @@ mod tests { fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) .expect("parse"); - assert_matches!( - cli.subcommand, - Some(Subcommand::RemoteControl(RemoteControlCommand { - subcommand: None - })) - ); + let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { + panic!("expected remote-control subcommand"); + }; + assert_eq!(remote_control.subcommand_name(), "remote-control"); let err = reject_remote_mode_for_subcommand( cli.remote.remote.as_deref(), diff --git a/codex-rs/cli/src/remote_control_cmd.rs b/codex-rs/cli/src/remote_control_cmd.rs new file mode 100644 index 0000000000..1ff8bd7595 --- /dev/null +++ b/codex-rs/cli/src/remote_control_cmd.rs @@ -0,0 +1,695 @@ +use std::io::Write; +use std::time::Duration; + +use anyhow::Context; +use clap::Args; +use codex_app_server::AppServerRuntimeOptions; +use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthSettings; +use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; +use codex_app_server_daemon::LifecycleOutput as AppServerLifecycleOutput; +use codex_app_server_daemon::LifecycleStatus as AppServerLifecycleStatus; +use codex_app_server_daemon::RemoteControlReadyOutput as AppServerRemoteControlReadyOutput; +use codex_app_server_daemon::RemoteControlReadyStatus as AppServerRemoteControlReadyStatus; +use codex_app_server_daemon::RemoteControlStartOutput as AppServerRemoteControlStartOutput; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const FOREGROUND_SOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const FOREGROUND_SOCKET_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(50); +const FOREGROUND_APP_SERVER_ABORT_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Debug, Args)] +pub(crate) struct RemoteControlCommand { + /// Emit machine-readable JSON. + #[arg(long = "json", global = true)] + json: bool, + + #[command(subcommand)] + subcommand: Option, +} + +impl RemoteControlCommand { + pub(crate) fn subcommand_name(&self) -> &'static str { + match self.subcommand { + None => "remote-control", + Some(RemoteControlSubcommand::Start) => "remote-control start", + Some(RemoteControlSubcommand::Stop) => "remote-control stop", + } + } +} + +#[derive(Debug, Clone, Copy, clap::Subcommand)] +enum RemoteControlSubcommand { + /// Start the app-server daemon with remote control enabled. + Start, + + /// Stop the app-server daemon. + Stop, +} + +pub(crate) async fn run( + command: RemoteControlCommand, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + match command.subcommand { + None => { + print_remote_control_progress( + command.json, + "Starting app-server with remote control enabled...", + )?; + run_foreground_remote_control(command.json, arg0_paths, root_config_overrides).await?; + } + Some(RemoteControlSubcommand::Start) => { + print_remote_control_progress( + command.json, + "Starting app-server daemon with remote control enabled...", + )?; + let output = codex_app_server_daemon::ensure_remote_control_ready().await?; + print_remote_control_start_output(&output, command.json)?; + } + Some(RemoteControlSubcommand::Stop) => { + print_remote_control_progress(command.json, "Stopping remote control...")?; + let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Stop).await?; + print_remote_control_stop_output(&output, command.json)?; + } + } + Ok(()) +} + +fn print_remote_control_progress(json: bool, message: &str) -> anyhow::Result<()> { + if json { + return Ok(()); + } + + println!("{message}"); + std::io::stdout() + .flush() + .context("failed to flush remote-control progress message")?; + Ok(()) +} + +async fn run_foreground_remote_control( + json: bool, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + let socket_dir = tempfile::Builder::new() + .prefix("codex-rc-") + .tempdir_in("/tmp") + .or_else(|_| tempfile::tempdir()) + .context("failed to create private app-server socket directory")?; + let socket_path = socket_dir.path().join("rc.sock"); + let socket_path = AbsolutePathBuf::from_absolute_path(&socket_path) + .context("private app-server socket path was not absolute")?; + let transport = AppServerTransport::UnixSocket { + socket_path: socket_path.clone(), + }; + let runtime_options = AppServerRuntimeOptions { + remote_control_enabled: true, + install_shutdown_signal_handler: false, + ..Default::default() + }; + let (stop_rx, stop_signal_task) = foreground_stop_signal(); + let mut app_server_task = tokio::spawn(codex_app_server::run_main_with_transport_options( + arg0_paths, + root_config_overrides, + LoaderOverrides::default(), + /*strict_config*/ false, + /*default_analytics_enabled*/ false, + transport, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + runtime_options, + )); + + let summary = match wait_for_foreground_remote_control_start( + &mut app_server_task, + wait_for_foreground_remote_control_ready(socket_path), + stop_rx.clone(), + ) + .await + { + ForegroundStartupResult::Ready(summary) => summary, + ForegroundStartupResult::Stopped => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + ForegroundStartupResult::ReadyFailed(error) => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + ForegroundStartupResult::AppServerExited(error) => { + stop_signal_task.abort(); + return Err(error); + } + }; + + if *stop_rx.borrow() { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + + if let Err(error) = print_foreground_ready_output(&summary, json) { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + + let result = wait_for_foreground_app_server(app_server_task, stop_rx).await; + stop_signal_task.abort(); + result +} + +fn foreground_stop_signal() -> (watch::Receiver, JoinHandle<()>) { + let (stop_tx, stop_rx) = watch::channel(false); + let task = tokio::spawn(async move { + if let Err(err) = tokio::signal::ctrl_c().await { + eprintln!("failed to listen for Ctrl-C: {err}"); + } + let _ = stop_tx.send(true); + }); + (stop_rx, task) +} + +enum ForegroundStartupResult { + Ready(AppServerRemoteControlReadyStatus), + Stopped, + ReadyFailed(anyhow::Error), + AppServerExited(anyhow::Error), +} + +async fn wait_for_foreground_remote_control_start( + app_server_task: &mut JoinHandle>, + ready: impl std::future::Future>, + mut stop_rx: watch::Receiver, +) -> ForegroundStartupResult { + tokio::pin!(ready); + + tokio::select! { + ready_result = &mut ready => match ready_result { + Ok(summary) => ForegroundStartupResult::Ready(summary), + Err(error) => ForegroundStartupResult::ReadyFailed(error), + }, + app_server_result = app_server_task => { + ForegroundStartupResult::AppServerExited( + foreground_app_server_exited_before_ready(app_server_result) + ) + } + _ = wait_for_stop_signal(&mut stop_rx) => ForegroundStartupResult::Stopped, + } +} + +async fn wait_for_foreground_app_server( + mut app_server_task: JoinHandle>, + mut stop_rx: watch::Receiver, +) -> anyhow::Result<()> { + tokio::select! { + app_server_result = &mut app_server_task => { + app_server_result + .context("foreground app-server task failed to join")? + .context("foreground app-server exited with an error")?; + } + _ = wait_for_stop_signal(&mut stop_rx) => { + abort_foreground_app_server(app_server_task).await; + } + } + + Ok(()) +} + +async fn wait_for_stop_signal(stop_rx: &mut watch::Receiver) { + if *stop_rx.borrow() { + return; + } + let _ = stop_rx.wait_for(|stopped| *stopped).await; +} + +fn foreground_app_server_exited_before_ready( + result: Result, tokio::task::JoinError>, +) -> anyhow::Error { + match result { + Ok(Ok(())) => { + anyhow::anyhow!("foreground app-server exited before remote control became ready") + } + Ok(Err(error)) => anyhow::Error::new(error) + .context("foreground app-server exited before remote control became ready"), + Err(error) => anyhow::Error::new(error) + .context("foreground app-server task failed before remote control became ready"), + } +} + +async fn abort_foreground_app_server(app_server_task: JoinHandle>) { + app_server_task.abort(); + let _ = timeout(FOREGROUND_APP_SERVER_ABORT_TIMEOUT, app_server_task).await; +} + +async fn wait_for_foreground_remote_control_ready( + socket_path: AbsolutePathBuf, +) -> anyhow::Result { + codex_app_server_daemon::enable_remote_control_on_socket( + socket_path.as_path(), + FOREGROUND_SOCKET_CONNECT_TIMEOUT, + FOREGROUND_SOCKET_CONNECT_RETRY_DELAY, + ) + .await +} + +fn print_remote_control_start_output( + output: &AppServerRemoteControlReadyOutput, + json: bool, +) -> anyhow::Result<()> { + ensure_remote_control_startable(&output.remote_control)?; + if json { + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::daemon(output))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines( + &output.remote_control, + RemoteControlHumanOutputMode::Daemon, + )? { + println!("{line}"); + } + for line in daemon_app_server_human_lines(&output.daemon) { + println!("{line}"); + } + Ok(()) +} + +fn print_foreground_ready_output( + summary: &AppServerRemoteControlReadyStatus, + json: bool, +) -> anyhow::Result<()> { + if json { + ensure_remote_control_startable(summary)?; + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::foreground(summary))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines(summary, RemoteControlHumanOutputMode::Foreground)? + { + println!("{line}"); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RemoteControlStartJsonOutput<'a> { + mode: RemoteControlModeJson, + status: RemoteControlConnectionStatus, + server_name: &'a str, + environment_id: Option<&'a str>, + timed_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + daemon: Option<&'a AppServerRemoteControlStartOutput>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum RemoteControlModeJson { + Foreground, + Daemon, +} + +impl<'a> RemoteControlStartJsonOutput<'a> { + fn foreground(summary: &'a AppServerRemoteControlReadyStatus) -> Self { + Self { + mode: RemoteControlModeJson::Foreground, + status: summary.status, + server_name: &summary.server_name, + environment_id: summary.environment_id.as_deref(), + timed_out: summary.timed_out, + daemon: None, + } + } + + fn daemon(output: &'a AppServerRemoteControlReadyOutput) -> Self { + let remote_control = &output.remote_control; + Self { + mode: RemoteControlModeJson::Daemon, + status: remote_control.status, + server_name: &remote_control.server_name, + environment_id: remote_control.environment_id.as_deref(), + timed_out: remote_control.timed_out, + daemon: Some(&output.daemon), + } + } +} + +fn remote_control_start_human_message( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result { + ensure_remote_control_startable(output)?; + match output.status { + RemoteControlConnectionStatus::Connected => Ok(format!( + "This machine is available for remote control as {}.", + output.server_name + )), + RemoteControlConnectionStatus::Connecting => Ok(format!( + "Remote control is enabled on {} and still connecting.", + output.server_name + )), + RemoteControlConnectionStatus::Errored | RemoteControlConnectionStatus::Disabled => { + unreachable!("errored and disabled statuses are rejected before formatting") + } + } +} + +fn ensure_remote_control_startable( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result<()> { + match output.status { + RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting => { + Ok(()) + } + RemoteControlConnectionStatus::Errored => { + anyhow::bail!( + "Remote control is enabled on {} but the connection is errored.", + output.server_name + ); + } + RemoteControlConnectionStatus::Disabled => { + anyhow::bail!("Remote control is disabled on {}.", output.server_name); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteControlHumanOutputMode { + Foreground, + Daemon, +} + +fn remote_control_start_human_lines( + summary: &AppServerRemoteControlReadyStatus, + mode: RemoteControlHumanOutputMode, +) -> anyhow::Result> { + let mut lines = vec![remote_control_start_human_message(summary)?]; + match mode { + RemoteControlHumanOutputMode::Foreground => { + lines.push("Press Ctrl-C to stop.".to_string()); + } + RemoteControlHumanOutputMode::Daemon => {} + } + Ok(lines) +} + +fn daemon_app_server_human_lines(output: &AppServerRemoteControlStartOutput) -> Vec { + let (managed_codex_path, managed_codex_version) = daemon_app_server_identity(output); + vec![ + "Daemon used app-server:".to_string(), + format!(" path: {}", managed_codex_path.display()), + format!(" version: {}", managed_codex_version.unwrap_or("unknown")), + ] +} + +fn daemon_app_server_identity( + output: &AppServerRemoteControlStartOutput, +) -> (&std::path::Path, Option<&str>) { + match output { + AppServerRemoteControlStartOutput::Bootstrap(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + AppServerRemoteControlStartOutput::Start(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + } +} + +fn print_remote_control_stop_output( + output: &AppServerLifecycleOutput, + json: bool, +) -> anyhow::Result<()> { + if json { + println!("{}", serde_json::to_string(output)?); + return Ok(()); + } + + println!("{}", remote_control_stop_human_message(output)); + Ok(()) +} + +fn remote_control_stop_human_message(output: &AppServerLifecycleOutput) -> String { + match output.status { + AppServerLifecycleStatus::Stopped => "Remote control stopped.".to_string(), + AppServerLifecycleStatus::NotRunning => "Remote control is not running.".to_string(), + AppServerLifecycleStatus::Started + | AppServerLifecycleStatus::Restarted + | AppServerLifecycleStatus::AlreadyRunning + | AppServerLifecycleStatus::Running => { + format!( + "Remote control stop completed with status {:?}.", + output.status + ) + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + use std::path::PathBuf; + + use super::*; + + fn remote_control_status( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyStatus { + AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + } + } + + fn daemon_ready_output( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyOutput { + AppServerRemoteControlReadyOutput { + daemon: AppServerRemoteControlStartOutput::Start(AppServerLifecycleOutput { + status: AppServerLifecycleStatus::Started, + backend: None, + pid: Some(42), + managed_codex_path: PathBuf::from("/opt/codex/bin/codex"), + managed_codex_version: Some("1.0.0".to_string()), + socket_path: PathBuf::from("/tmp/app-server-control.sock"), + cli_version: Some("1.0.0".to_string()), + app_server_version: Some("2.0.0".to_string()), + }), + remote_control: AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + }, + } + } + + #[test] + fn remote_control_human_start_messages_use_server_name() { + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connected + )) + .expect("connected message"), + "This machine is available for remote control as owen-mbp." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connecting + )) + .expect("connecting message"), + "Remote control is enabled on owen-mbp and still connecting." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Errored + )) + .expect_err("errored status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Disabled + )) + .expect_err("disabled status should fail") + .to_string(), + "Remote control is disabled on owen-mbp." + ); + } + + #[test] + fn remote_control_human_lines_include_foreground_stop_hint_only() { + let summary = remote_control_status(RemoteControlConnectionStatus::Connected); + + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Foreground) + .expect("foreground lines"), + vec![ + "This machine is available for remote control as owen-mbp.".to_string(), + "Press Ctrl-C to stop.".to_string(), + ] + ); + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Daemon) + .expect("daemon lines"), + vec!["This machine is available for remote control as owen-mbp.".to_string()] + ); + } + + #[test] + fn daemon_app_server_human_lines_include_path_and_version() { + assert_eq!( + daemon_app_server_human_lines( + &daemon_ready_output(RemoteControlConnectionStatus::Connected).daemon + ), + vec![ + "Daemon used app-server:".to_string(), + " path: /opt/codex/bin/codex".to_string(), + " version: 1.0.0".to_string(), + ] + ); + } + + #[test] + fn remote_control_json_output_marks_foreground_or_daemon() { + let foreground_summary = remote_control_status(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::foreground( + &foreground_summary + )) + .expect("foreground JSON"), + json!({ + "mode": "foreground", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + }) + ); + + let daemon_output = daemon_ready_output(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::daemon(&daemon_output)) + .expect("daemon JSON"), + json!({ + "mode": "daemon", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + "daemon": { + "status": "started", + "pid": 42, + "managedCodexPath": "/opt/codex/bin/codex", + "managedCodexVersion": "1.0.0", + "socketPath": "/tmp/app-server-control.sock", + "cliVersion": "1.0.0", + "appServerVersion": "2.0.0", + }, + }) + ); + } + + #[test] + fn remote_control_daemon_json_rejects_unstartable_status() { + assert_eq!( + print_remote_control_start_output( + &daemon_ready_output(RemoteControlConnectionStatus::Errored), + /*json*/ true + ) + .expect_err("errored daemon status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + } + + #[tokio::test] + async fn foreground_wait_aborts_app_server_on_stop_signal() { + let app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_app_server(app_server_task, stop_rx), + ) + .await + .expect("foreground wait should return after stop signal") + .expect("stop signal should shut down cleanly"); + } + + #[tokio::test] + async fn foreground_start_wait_stops_before_ready() { + let mut app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after stop signal"); + + assert!(matches!(startup, ForegroundStartupResult::Stopped)); + app_server_task.abort(); + let _ = app_server_task.await; + } + + #[tokio::test] + async fn foreground_start_wait_reports_app_server_exit_before_ready() { + let mut app_server_task = + tokio::spawn(async { Err(std::io::Error::other("startup failed before socket bind")) }); + let (_stop_tx, stop_rx) = tokio::sync::watch::channel(false); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after app-server exits"); + + let ForegroundStartupResult::AppServerExited(error) = startup else { + panic!("expected app-server exit before ready"); + }; + + assert_eq!( + error.to_string(), + "foreground app-server exited before remote control became ready" + ); + } +} From c95a70fb420214761de942427d870aca1c71718a Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 18 May 2026 14:09:25 -0700 Subject: [PATCH 080/114] Publish Linux runtime wheels with glibc-compatible tags (#21812) ## Why The Python SDK depends on `openai-codex-cli-bin` runtime wheels being installable on the Linux hosts our users actually run. The release workflow currently tags the Linux runtime artifacts as `musllinux_*`, which makes pip ignore them on normal glibc distributions even though the bundled Rust executables are intended to run there. ## What changed - Tag the Linux runtime wheels as `manylinux_2_17_aarch64` and `manylinux_2_17_x86_64` instead of `musllinux_1_1_*`. - Keep the existing runtime wheel build and publish flow unchanged otherwise. ## Verification - Confirmed the wheel-tag issue against the PyPA platform-tag rules for `manylinux` vs `musllinux`. - This PR is now intentionally scoped to the tag correction only; the broader Python runtime release workflow has already landed on `main` through the merged stack. ## Follow-up After publishing the next alpha from this branch, install the SDK/runtime in a fresh glibc Linux environment and confirm pip resolves the tagged Linux wheel as expected. Co-authored-by: Codex --- .github/workflows/rust-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b6c293d6cd..50953506d3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -533,10 +533,10 @@ jobs: platform_tag="macosx_10_9_x86_64" ;; aarch64-unknown-linux-musl) - platform_tag="musllinux_1_1_aarch64" + platform_tag="manylinux_2_17_aarch64" ;; x86_64-unknown-linux-musl) - platform_tag="musllinux_1_1_x86_64" + platform_tag="manylinux_2_17_x86_64" ;; *) echo "No Python runtime wheel platform tag for ${{ matrix.target }}" From f2368b7de61c6a5adadec7cba3597097f1e8e520 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 14:54:36 -0700 Subject: [PATCH 081/114] [codex] Trim unused TurnContextItem fields (#22709) ## Why `TurnContextItem` is the durable baseline used to reconstruct context diffs across resume/fork. Most of the old persisted-only fields on it are no longer read, so keeping them in rollout snapshots adds schema surface and state that can drift without affecting reconstruction. `summary` is the exception: older Codex versions require it to deserialize `turn_context` records, so keep writing a default compatibility value until that schema surface can be removed safely. ## What changed - Removed the unused persisted fields from `TurnContextItem`: trace ids, user/developer instructions, output schema, and truncation policy. - Kept `summary` with a compatibility comment and made `TurnContext::to_turn_context_item` write `ReasoningSummary::Auto` instead of live turn state. - Updated rollout/context reconstruction fixtures for the retained summary field. ## Verification - `cargo test -p codex-protocol --lib turn_context_item` - `cargo test -p codex-rollout resume_candidate_matches_cwd_reads_latest_turn_context` - `cargo test -p codex-state turn_context` - `cargo test -p codex-core --lib new_default_turn_captures_current_span_trace_id` - `cargo test -p codex-core --lib record_initial_history_resumed_turn_context_after_compaction_reestablishes_reference_context_item` - `cargo test -p codex-core --test all emits_warning_when_resumed_model_differs` - `git diff --check` --- .../core/src/context_manager/history_tests.rs | 8 +-- .../session/rollout_reconstruction_tests.rs | 56 +++---------------- codex-rs/core/src/session/tests.rs | 16 ++---- codex-rs/core/src/session/turn_context.rs | 7 +-- codex-rs/core/tests/suite/resume_warning.rs | 5 -- codex-rs/protocol/src/protocol.rs | 21 ++----- codex-rs/rollout/src/recorder_tests.rs | 8 +-- codex-rs/state/src/extract.rs | 22 +------- 8 files changed, 24 insertions(+), 119 deletions(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 74f4d29bfb..8ba5d56e32 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -2,7 +2,6 @@ use super::*; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -121,7 +120,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), - trace_id: None, cwd: PathBuf::from("/tmp/reference-cwd"), current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), @@ -135,11 +133,7 @@ fn reference_context_item() -> TurnContextItem { collaboration_mode: None, realtime_active: Some(false), effort: None, - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)), + summary: codex_protocol::config_types::ReasoningSummary::Auto, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index aa403316e0..fc2811710a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -59,7 +59,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -74,11 +73,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -101,7 +96,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let previous_model = "previous-rollout-model"; let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -116,11 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item .turn_id @@ -925,7 +915,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -940,11 +929,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1005,7 +990,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis .expect("serialize seeded reference context item"), serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1020,11 +1004,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") ); @@ -1037,7 +1017,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1052,11 +1031,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1155,7 +1130,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let unmatched_abort_turn_id = "other-turn".to_string(); let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1170,11 +1144,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![ @@ -1272,7 +1242,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1287,11 +1256,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id @@ -1428,7 +1393,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_model = "previous-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -1443,11 +1407,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item .turn_id diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index dbdc8aa8dd..e3f038eff7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2317,7 +2317,6 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_model = "forked-rollout-model"; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), - trace_id: turn_context.trace_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), @@ -2332,11 +2331,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, - summary: turn_context.reasoning_summary, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy), + summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item .turn_id @@ -5125,7 +5120,7 @@ async fn new_default_turn_captures_current_span_trace_id() { &request_parent )); - let turn_context_item = async { + let turn_trace_id = async { let expected_trace_id = Span::current() .context() .span() @@ -5133,15 +5128,14 @@ async fn new_default_turn_captures_current_span_trace_id() { .trace_id() .to_string(); let turn_context = session.new_default_turn().await; - let turn_context_item = turn_context.to_turn_context_item(); - assert_eq!(turn_context_item.trace_id, Some(expected_trace_id)); - turn_context_item + assert_eq!(turn_context.trace_id, Some(expected_trace_id)); + turn_context.trace_id.clone() } .instrument(request_span) .await; assert_eq!( - turn_context_item.trace_id.as_deref(), + turn_trace_id.as_deref(), Some("00000000000000000000000000000011") ); } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8f0ce2d5fd..b14999d2cc 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -368,7 +368,6 @@ impl TurnContext { pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { TurnContextItem { turn_id: Some(self.sub_id.clone()), - trace_id: self.trace_id.clone(), #[allow(deprecated)] cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), @@ -383,11 +382,7 @@ impl TurnContext { collaboration_mode: Some(self.collaboration_mode.clone()), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, - summary: self.reasoning_summary, - user_instructions: self.user_instructions.clone(), - developer_instructions: self.developer_instructions.clone(), - final_output_json_schema: self.final_output_json_schema.clone(), - truncation_policy: Some(self.truncation_policy), + summary: ReasoningSummaryConfig::Auto, } } diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index ac916ba66d..d2bdcd1d32 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -27,7 +27,6 @@ fn resume_history( let turn_id = "resume-warning-seed-turn".to_string(); let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), - trace_id: None, cwd: config.cwd.to_path_buf(), current_date: None, timezone: None, @@ -44,10 +43,6 @@ fn resume_history( summary: config .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, }; InitialHistory::Resumed(ResumedHistory { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 98635ee30a..ec780d60c3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2824,8 +2824,6 @@ pub struct TurnContextNetworkItem { pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trace_id: Option, pub cwd: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, @@ -2848,15 +2846,11 @@ pub struct TurnContextItem { pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, + // Compatibility-only field written with a default value so older Codex + // versions can deserialize turn-context rollout items. It is no longer + // read by context reconstruction and should be removed in a future schema + // cleanup. pub summary: ReasoningSummaryConfig, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub developer_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub final_output_json_schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub truncation_policy: Option, } impl TurnContextItem { @@ -5246,7 +5240,6 @@ mod tests { "summary": "auto", }))?; - assert_eq!(item.trace_id, None); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); Ok(()) @@ -5256,7 +5249,6 @@ mod tests { fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, - trace_id: None, cwd: test_path_buf("/tmp"), current_date: None, timezone: None, @@ -5281,10 +5273,6 @@ mod tests { realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, }; let value = serde_json::to_value(item)?; @@ -5308,6 +5296,7 @@ mod tests { }] }) ); + assert_eq!(value["summary"], json!("auto")); Ok(()) } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 8199f290d8..90fe15c312 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -4,7 +4,6 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; use codex_protocol::ThreadId; -use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; @@ -1127,7 +1126,6 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timestamp: "2025-01-03T13:00:01Z".to_string(), item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: latest_cwd.clone(), current_date: None, timezone: None, @@ -1141,11 +1139,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re collaboration_mode: None, realtime_active: None, effort: None, - summary: ReasoningSummaryConfig::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), }; writeln!(file, "{}", serde_json::to_string(&turn_context)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 886d267e4f..2369addf23 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -156,7 +156,6 @@ mod tests { use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; - use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; @@ -339,7 +338,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/parent/workspace"), current_date: None, timezone: None, @@ -353,11 +351,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: None, - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); @@ -379,7 +373,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/fallback/workspace"), current_date: None, timezone: None, @@ -393,11 +386,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); @@ -413,7 +402,6 @@ mod tests { &mut metadata, &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), - trace_id: None, cwd: PathBuf::from("/fallback/workspace"), current_date: None, timezone: None, @@ -427,11 +415,7 @@ mod tests { collaboration_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, }), "test-provider", ); From a66e0e9c4b2978121ed1cd4242f7f62dd027423f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 18 May 2026 15:33:33 -0700 Subject: [PATCH 082/114] Include plugin id in plugin MCP tool metadata (#23353) Adding the id of the plugin that contains the MCP (if any) so we can apply filters at plugin level. ## Summary - carry the plugin owner into MCP runtime provenance - attach `plugin_id` to outbound plugin-backed MCP tool-call `_meta` - avoid misattributing user-configured MCP servers that shadow plugin server names ## Testing - `just fmt` - `just fix -p codex-mcp` - `just fix -p codex-core` - `cargo test -p codex-mcp` - `cargo test -p codex-core plugin_mcp_tool_call_request_meta_includes_plugin_id` - `cargo test -p codex-core to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp` - `cargo test -p codex-core rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config` - `git diff --check` ## Notes - Attempted `cargo test -p codex-core`; it aborted in `agent::control::tests::resume_agent_from_rollout_skips_descendants_when_parent_resume_fails` with a stack overflow before the full suite completed. --- codex-rs/codex-mcp/src/connection_manager.rs | 8 +++ codex-rs/codex-mcp/src/mcp/mod.rs | 17 ++++-- codex-rs/codex-mcp/src/mcp/mod_tests.rs | 18 ++++++- codex-rs/core/src/config/config_tests.rs | 57 ++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 10 +++- codex-rs/core/src/mcp_tool_call.rs | 21 +++++--- codex-rs/core/src/mcp_tool_call_tests.rs | 37 +++++++++++++ 7 files changed, 155 insertions(+), 13 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index e8dc36b9b2..8144f5b227 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -71,6 +71,7 @@ use tracing::warn; pub struct McpConnectionManager { clients: HashMap, server_metadata: HashMap, + tool_plugin_provenance: Arc, host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, @@ -91,6 +92,7 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_metadata: HashMap::new(), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), @@ -136,6 +138,11 @@ impl McpConnectionManager { .is_none_or(|metadata| metadata.pollutes_memory) } + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.tool_plugin_provenance + .plugin_id_for_mcp_server_name(server_name) + } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME } @@ -283,6 +290,7 @@ impl McpConnectionManager { let manager = Self { clients, server_metadata, + tool_plugin_provenance, host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 2ce03c9317..3d1969de0c 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -138,6 +138,8 @@ pub struct McpConfig { /// /// Runtime-only additions are merged later by [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, + /// Winning plugin owner for plugin-provided MCP servers, keyed by server name. + pub plugin_ids_by_mcp_server_name: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, } @@ -146,6 +148,7 @@ pub struct McpConfig { pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, plugin_display_names_by_mcp_server_name: HashMap>, + plugin_ids_by_mcp_server_name: HashMap, } impl ToolPluginProvenance { @@ -163,9 +166,15 @@ impl ToolPluginProvenance { .unwrap_or(&[]) } - fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.plugin_ids_by_mcp_server_name + .get(server_name) + .map(String::as_str) + } + + fn from_config(config: &McpConfig) -> Self { let mut tool_plugin_provenance = Self::default(); - for plugin in capability_summaries { + for plugin in &config.plugin_capability_summaries { for connector_id in &plugin.app_connector_ids { tool_plugin_provenance .plugin_display_names_by_connector_id @@ -195,6 +204,8 @@ impl ToolPluginProvenance { plugin_names.sort_unstable(); plugin_names.dedup(); } + tool_plugin_provenance.plugin_ids_by_mcp_server_name = + config.plugin_ids_by_mcp_server_name.clone(); tool_plugin_provenance } @@ -244,7 +255,7 @@ pub fn effective_mcp_servers_from_configured( } pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { - ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries) + ToolPluginProvenance::from_config(config) } pub async fn read_mcp_resource( diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 99622d7f07..7566bf9bde 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -30,6 +30,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { apps_enabled: false, client_elicitation_capability: ElicitationCapability::default(), configured_mcp_servers: HashMap::new(), + plugin_ids_by_mcp_server_name: HashMap::new(), plugin_capability_summaries: Vec::new(), } } @@ -124,7 +125,10 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { #[test] fn tool_plugin_provenance_collects_app_and_mcp_sources() { - let provenance = ToolPluginProvenance::from_capability_summaries(&[ + let mut config = test_mcp_config(PathBuf::new()); + config.plugin_ids_by_mcp_server_name = + HashMap::from([("alpha".to_string(), "alpha@test".to_string())]); + config.plugin_capability_summaries = vec![ PluginCapabilitySummary { display_name: "alpha-plugin".to_string(), app_connector_ids: vec![AppConnectorId("connector_example".to_string())], @@ -140,7 +144,8 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { mcp_server_names: vec!["beta".to_string()], ..PluginCapabilitySummary::default() }, - ]); + ]; + let provenance = tool_plugin_provenance(&config); assert_eq!( provenance, @@ -159,8 +164,17 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { ("alpha".to_string(), vec!["alpha-plugin".to_string()]), ("beta".to_string(), vec!["beta-plugin".to_string()]), ]), + plugin_ids_by_mcp_server_name: HashMap::from([( + "alpha".to_string(), + "alpha@test".to_string(), + )]), } ); + assert_eq!( + provenance.plugin_id_for_mcp_server_name("alpha"), + Some("alpha@test") + ); + assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None); } #[test] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21d291b309..50b03b5051 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3849,6 +3849,63 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() mcp_config.configured_mcp_servers.get("sample"), Some(&http_mcp("https://sample.example/mcp")) ); + assert_eq!( + mcp_config.plugin_ids_by_mcp_server_name, + HashMap::from([("sample".to_string(), "sample@test".to_string())]) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://plugin.example/mcp" + } + } +}"#, + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[mcp_servers.sample] +url = "https://user.example/mcp" + +[plugins."sample@test"] +enabled = true +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://user.example/mcp")) + ); + assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty()); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6eb66c4543..583c2a42b6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -107,6 +107,7 @@ use serde::Serialize; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -1243,6 +1244,7 @@ impl Config { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; let mut configured_mcp_servers = self.mcp_servers.get().clone(); + let mut plugin_ids_by_mcp_server_name = HashMap::new(); for plugin in loaded_plugins .plugins() .iter() @@ -1255,7 +1257,10 @@ impl Config { self.config_layer_stack.requirements().plugins.as_ref(), ); for (name, plugin_server) in plugin_mcp_servers { - configured_mcp_servers.entry(name).or_insert(plugin_server); + if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) { + entry.insert(plugin_server); + plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone()); + } } } if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() @@ -1265,6 +1270,8 @@ impl Config { // above. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + plugin_ids_by_mcp_server_name + .retain(|server_name, _| configured_mcp_servers.contains_key(server_name)); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), @@ -1292,6 +1299,7 @@ impl Config { ElicitationCapability::default() }, configured_mcp_servers, + plugin_ids_by_mcp_server_name, plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8f6e3afaf5..902ede574e 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -974,6 +974,7 @@ pub(crate) struct McpToolApprovalMetadata { connector_id: Option, connector_name: Option, connector_description: Option, + plugin_id: Option, tool_title: Option, tool_description: Option, mcp_app_resource_uri: Option, @@ -983,6 +984,7 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; +const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; async fn custom_mcp_tool_approval_mode( @@ -1068,6 +1070,12 @@ fn build_mcp_tool_call_request_meta( serde_json::Value::Object(codex_apps_meta), ); } + if let Some(plugin_id) = metadata.and_then(|metadata| metadata.plugin_id.as_ref()) { + request_meta.insert( + MCP_TOOL_PLUGIN_ID_META_KEY.to_string(), + serde_json::Value::String(plugin_id.clone()), + ); + } (!request_meta.is_empty()).then_some(serde_json::Value::Object(request_meta)) } @@ -1480,13 +1488,11 @@ pub(crate) async fn lookup_mcp_tool_metadata( server: &str, tool_name: &str, ) -> Option { - let tools = sess - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; + let manager = sess.services.mcp_connection_manager.read().await; + let plugin_id = manager + .plugin_id_for_mcp_server_name(server) + .map(str::to_string); + let tools = manager.list_all_tools().await; let tool_info = tools .into_iter() .find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?; @@ -1519,6 +1525,7 @@ pub(crate) async fn lookup_mcp_tool_metadata( connector_id: tool_info.connector_id, connector_name: tool_info.connector_name, connector_description, + plugin_id, tool_title: tool_info.tool.title, tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned), mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index d8b6326cd0..25f19c87dd 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -76,6 +76,7 @@ fn approval_metadata( connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), connector_description: connector_description.map(str::to_string), + plugin_id: None, tool_title: tool_title.map(str::to_string), tool_description: tool_description.map(str::to_string), mcp_app_resource_uri: None, @@ -1099,6 +1100,29 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { ); } +#[tokio::test] +async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { + let (_, turn_context) = make_session_and_context().await; + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); + let mut metadata = approval_metadata( + /*connector_id*/ None, /*connector_name*/ None, + /*connector_description*/ None, /*tool_title*/ None, + /*tool_description*/ None, + ); + metadata.plugin_id = Some("sample@test".to_string()); + + assert_eq!( + build_mcp_tool_call_request_meta(&turn_context, "sample", "call-plugin", Some(&metadata),), + Some(serde_json::json!({ + crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, + MCP_TOOL_PLUGIN_ID_META_KEY: "sample@test", + })) + ); +} + #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; @@ -1111,6 +1135,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Create Event".to_string()), tool_description: Some("Create a calendar event.".to_string()), mcp_app_resource_uri: None, @@ -1573,6 +1598,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: None, tool_description: None, mcp_app_resource_uri: None, @@ -2290,6 +2316,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2363,6 +2390,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2419,6 +2447,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Create entities".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2554,6 +2583,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Create entities".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2640,6 +2670,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Reads calendar data.".to_string()), mcp_app_resource_uri: None, @@ -2693,6 +2724,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Read Only Tool".to_string()), tool_description: None, mcp_app_resource_uri: None, @@ -2772,6 +2804,7 @@ async fn approve_mode_skips_arc_interrupt_for_model() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2839,6 +2872,7 @@ async fn custom_approve_mode_skips_arc_interrupt_for_model() { connector_id: None, connector_name: None, connector_description: None, + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2906,6 +2940,7 @@ async fn approve_mode_skips_arc_interrupt_without_annotations() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -2978,6 +3013,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, @@ -3046,6 +3082,7 @@ async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() { connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), + plugin_id: None, tool_title: Some("Dangerous Tool".to_string()), tool_description: Some("Performs a risky action.".to_string()), mcp_app_resource_uri: None, From afa0101ae222792c1c7e072107e1eb7b72c19b7b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 15:43:01 -0700 Subject: [PATCH 083/114] [codex] Move pending input into input queue (#22728) ## Why Pending model input was split across `Session`, `TurnState`, and the agent mailbox. That made it easy for new paths to manage queued user input or mailbox delivery outside the intended ownership boundary. This PR consolidates the model-facing input lifecycle behind the session input queue so turn-local pending input, next-turn queued items, and mailbox delivery coordination are owned in one place. ## What Changed - Added `session/input_queue.rs` to own pending input queues and mailbox delivery coordination. - Removed the standalone `agent/mailbox.rs` channel wrapper and store mailbox items directly in the input queue. - Moved pending-input mutations off `TurnState`; `TurnState` now exposes the queue-owned storage directly for now. - Routed abort cleanup, mailbox delivery phase changes, next-turn queued items, and active-turn pending input through `InputQueue`. - Boxed stack-heavy agent resume/fork startup futures that the refactor pushed over the default test stack. - Updated session, task, goal, stream-event, and multi-agent call sites and tests to use the new queue ownership. ## Verification - `cargo test -p codex-core --lib agent::control::tests` - `cargo test -p codex-core --lib agent::control::tests::resume_closed_child_reopens_open_descendants -- --exact` - `cargo test -p codex-core --lib agent::control::tests::spawn_agent_fork_last_n_turns_keeps_only_recent_turns -- --exact` - `cargo test -p codex-core --lib agent::control::tests::resume_thread_subagent_restores_stored_nickname_and_role -- --exact` - `cargo test -p codex-core` was also run; it completed with 1814 passed, 4 ignored, and one timeout in `agent::control::tests::resume_thread_subagent_restores_stored_nickname_and_role`, which passed when rerun in isolation. --- codex-rs/core/src/agent/control.rs | 13 +- codex-rs/core/src/agent/control_tests.rs | 8 +- codex-rs/core/src/agent/mailbox.rs | 161 ------- codex-rs/core/src/agent/mod.rs | 3 - codex-rs/core/src/codex_thread.rs | 1 + codex-rs/core/src/goals.rs | 24 +- codex-rs/core/src/session/handlers.rs | 8 +- codex-rs/core/src/session/input_queue.rs | 392 ++++++++++++++++++ codex-rs/core/src/session/mod.rs | 189 +-------- codex-rs/core/src/session/session.rs | 13 +- codex-rs/core/src/session/tests.rs | 129 +++--- codex-rs/core/src/session/turn.rs | 13 +- codex-rs/core/src/state/turn.rs | 42 +- codex-rs/core/src/stream_events_utils.rs | 9 +- codex-rs/core/src/tasks/mod.rs | 41 +- codex-rs/core/src/tasks/regular.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 85 ++-- .../tools/handlers/multi_agents_v2/wait.rs | 14 +- 18 files changed, 623 insertions(+), 524 deletions(-) delete mode 100644 codex-rs/core/src/agent/mailbox.rs create mode 100644 codex-rs/core/src/session/input_queue.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 71bc026a13..a246f0084f 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -499,13 +499,12 @@ impl AgentControl { agent_nickname: None, agent_role: None, }); - match self - .resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - ) - .await + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await { Ok(_) => true, Err(err) => { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b95aad4489..e5883f60da 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -486,7 +486,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig timeout(Duration::from_secs(5), async { loop { - if thread.codex.session.has_pending_input().await { + if thread + .codex + .session + .input_queue + .has_pending_input(&thread.codex.session.active_turn) + .await + { break; } sleep(Duration::from_millis(10)).await; diff --git a/codex-rs/core/src/agent/mailbox.rs b/codex-rs/core/src/agent/mailbox.rs deleted file mode 100644 index c328236475..0000000000 --- a/codex-rs/core/src/agent/mailbox.rs +++ /dev/null @@ -1,161 +0,0 @@ -use codex_protocol::protocol::InterAgentCommunication; -use std::collections::VecDeque; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use tokio::sync::mpsc; -use tokio::sync::watch; - -#[cfg(test)] -use codex_protocol::AgentPath; - -pub(crate) struct Mailbox { - tx: mpsc::UnboundedSender, - next_seq: AtomicU64, - seq_tx: watch::Sender, -} - -pub(crate) struct MailboxReceiver { - rx: mpsc::UnboundedReceiver, - pending_mails: VecDeque, -} - -impl Mailbox { - pub(crate) fn new() -> (Self, MailboxReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (seq_tx, _) = watch::channel(0); - ( - Self { - tx, - next_seq: AtomicU64::new(0), - seq_tx, - }, - MailboxReceiver { - rx, - pending_mails: VecDeque::new(), - }, - ) - } - - pub(crate) fn subscribe(&self) -> watch::Receiver { - self.seq_tx.subscribe() - } - - pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { - let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.tx.send(communication); - self.seq_tx.send_replace(seq); - seq - } -} - -impl MailboxReceiver { - fn sync_pending_mails(&mut self) { - while let Ok(mail) = self.rx.try_recv() { - self.pending_mails.push_back(mail); - } - } - - pub(crate) fn has_pending(&mut self) -> bool { - self.sync_pending_mails(); - !self.pending_mails.is_empty() - } - - pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { - self.sync_pending_mails(); - self.pending_mails.iter().any(|mail| mail.trigger_turn) - } - - pub(crate) fn drain(&mut self) -> Vec { - self.sync_pending_mails(); - self.pending_mails.drain(..).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn make_mail( - author: AgentPath, - recipient: AgentPath, - content: &str, - trigger_turn: bool, - ) -> InterAgentCommunication { - InterAgentCommunication::new( - author, - recipient, - Vec::new(), - content.to_string(), - trigger_turn, - ) - } - - #[tokio::test] - async fn mailbox_assigns_monotonic_sequence_numbers() { - let (mailbox, _receiver) = Mailbox::new(); - let mut seq_rx = mailbox.subscribe(); - - let seq_a = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - )); - let seq_b = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "two", - /*trigger_turn*/ false, - )); - - seq_rx.changed().await.expect("first seq update"); - assert_eq!(*seq_rx.borrow(), seq_b); - assert_eq!(seq_a, 1); - assert_eq!(seq_b, 2); - } - - #[tokio::test] - async fn mailbox_drains_in_delivery_order() { - let (mailbox, mut receiver) = Mailbox::new(); - let mail_one = make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - ); - let mail_two = make_mail( - AgentPath::try_from("/root/worker").expect("agent path"), - AgentPath::root(), - "two", - /*trigger_turn*/ false, - ); - - mailbox.send(mail_one.clone()); - mailbox.send(mail_two.clone()); - - assert_eq!(receiver.drain(), vec![mail_one, mail_two]); - assert!(!receiver.has_pending()); - } - - #[tokio::test] - async fn mailbox_tracks_pending_trigger_turn_mail() { - let (mailbox, mut receiver) = Mailbox::new(); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "queued", - /*trigger_turn*/ false, - )); - assert!(!receiver.has_pending_trigger_turn()); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "wake", - /*trigger_turn*/ true, - )); - assert!(receiver.has_pending_trigger_turn()); - } -} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index a60fc3004a..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,14 +1,11 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -pub(crate) mod mailbox; mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use mailbox::Mailbox; -pub(crate) use mailbox::MailboxReceiver; pub(crate) use registry::exceeds_thread_spawn_depth_limit; pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7e9e6e38cd..8f5023300c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -383,6 +383,7 @@ impl CodexThread { { self.codex .session + .input_queue .queue_response_items_for_next_turn(items) .await; self.codex.session.maybe_start_turn_for_pending_work().await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 18b7c77bda..13f8cc95c8 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1331,12 +1331,9 @@ impl Session { .await; return; } - { - let mut turn_state = turn_state.lock().await; - for item in candidate.items { - turn_state.push_pending_input(item); - } - } + self.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), candidate.items) + .await; let turn_context = self .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) @@ -1374,11 +1371,15 @@ impl Session { tracing::debug!("skipping active goal continuation because a turn is already active"); return None; } - if self.has_queued_response_items_for_next_turn().await { + if self + .input_queue + .has_queued_response_items_for_next_turn() + .await + { tracing::debug!("skipping active goal continuation because queued input exists"); return None; } - if self.has_trigger_turn_mailbox_items().await { + if self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!( "skipping active goal continuation because trigger-turn mailbox input is pending" ); @@ -1415,8 +1416,11 @@ impl Session { return None; } if self.active_turn.lock().await.is_some() - || self.has_queued_response_items_for_next_turn().await - || self.has_trigger_turn_mailbox_items().await + || self + .input_queue + .has_queued_response_items_for_next_turn() + .await + || self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!("skipping active goal continuation because pending work appeared"); return None; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index c2ffe2f4e4..eb27ae78be 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -319,7 +319,9 @@ pub async fn inter_agent_communication( communication: InterAgentCommunication, ) { let trigger_turn = communication.trigger_turn; - sess.enqueue_mailbox_communication(communication); + sess.input_queue + .enqueue_mailbox_communication(communication) + .await; if trigger_turn { sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id) .await; @@ -961,7 +963,9 @@ Approved action: }]; if let Err(items) = sess.inject_response_items(items).await { - sess.queue_response_items_for_next_turn(items).await; + sess.input_queue + .queue_response_items_for_next_turn(items) + .await; } } diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs new file mode 100644 index 0000000000..2e8a6ded9d --- /dev/null +++ b/codex-rs/core/src/session/input_queue.rs @@ -0,0 +1,392 @@ +use crate::state::ActiveTurn; +use crate::state::MailboxDeliveryPhase; +use crate::state::TurnState; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::InterAgentCommunication; +use std::collections::VecDeque; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::watch; + +/// Turn-local pending input storage owned by the input queue flow. +#[derive(Default)] +pub(crate) struct TurnInputQueue { + items: Vec, +} + +/// Session-scoped pending input storage and active-turn mailbox delivery coordination. +pub(crate) struct InputQueue { + mailbox_tx: watch::Sender<()>, + mailbox_pending_mails: Mutex>, + + idle_pending_input: Mutex>, +} + +impl InputQueue { + pub(crate) fn new() -> Self { + let (mailbox_tx, _) = watch::channel(()); + Self { + mailbox_tx, + mailbox_pending_mails: Mutex::new(VecDeque::new()), + idle_pending_input: Mutex::new(Vec::new()), + } + } + + pub(crate) async fn subscribe_mailbox(&self) -> watch::Receiver<()> { + let mut mailbox_rx = self.mailbox_tx.subscribe(); + if self.has_pending_mailbox_items().await { + mailbox_rx.mark_changed(); + } + mailbox_rx + } + + pub(crate) async fn enqueue_mailbox_communication( + &self, + communication: InterAgentCommunication, + ) { + self.mailbox_pending_mails + .lock() + .await + .push_back(communication); + self.mailbox_tx.send_replace(()); + } + + pub(crate) async fn has_pending_mailbox_items(&self) -> bool { + !self.mailbox_pending_mails.lock().await.is_empty() + } + + pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool { + self.mailbox_pending_mails + .lock() + .await + .iter() + .any(|mail| mail.trigger_turn) + } + + pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { + self.mailbox_pending_mails + .lock() + .await + .drain(..) + .map(|mail| mail.to_response_input_item()) + .collect() + } + + pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { + if items.is_empty() { + return; + } + + self.idle_pending_input.lock().await.extend(items); + } + + pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { + std::mem::take(&mut *self.idle_pending_input.lock().await) + } + + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { + !self.idle_pending_input.lock().await.is_empty() + } + + pub(crate) async fn turn_state_for_sub_id( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) -> Option>> { + let active = active_turn.lock().await; + active.as_ref().and_then(|active_turn| { + active_turn + .tasks + .contains_key(sub_id) + .then(|| Arc::clone(&active_turn.turn_state)) + }) + } + + /// Clear any pending waiters and input buffered for the current turn. + pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) { + let mut turn_state = active_turn.turn_state.lock().await; + turn_state.clear_pending_waiters(); + turn_state.pending_input.items.clear(); + } + + pub(crate) async fn defer_mailbox_delivery_to_next_turn( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) { + let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + let mut turn_state = turn_state.lock().await; + if !turn_state.pending_input.items.is_empty() { + return; + } + turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn); + } + + pub(crate) async fn accept_mailbox_delivery_for_current_turn( + &self, + active_turn: &Mutex>, + sub_id: &str, + ) { + let turn_state = self.turn_state_for_sub_id(active_turn, sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + self.accept_mailbox_delivery_for_turn_state(turn_state.as_ref()) + .await; + } + + pub(super) async fn accept_mailbox_delivery_for_turn_state( + &self, + turn_state: &Mutex, + ) { + turn_state + .lock() + .await + .accept_mailbox_delivery_for_current_turn(); + } + + pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state( + &self, + turn_state: &Mutex, + input: ResponseInputItem, + ) { + let mut turn_state = turn_state.lock().await; + turn_state.pending_input.items.push(input); + turn_state.accept_mailbox_delivery_for_current_turn(); + } + + pub(crate) async fn extend_pending_input_for_turn_state( + &self, + turn_state: &Mutex, + input: Vec, + ) { + turn_state.lock().await.pending_input.items.extend(input); + } + + pub(crate) async fn take_pending_input_for_turn_state( + &self, + turn_state: &Mutex, + ) -> Vec { + turn_state.lock().await.pending_input.items.split_off(0) + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn inject_response_items( + &self, + active_turn: &Mutex>, + input: Vec, + ) -> Result<(), Vec> { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + active_turn + .turn_state + .lock() + .await + .pending_input + .items + .extend(input); + Ok(()) + } + None => Err(input), + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn prepend_pending_input( + &self, + active_turn: &Mutex>, + mut input: Vec, + ) -> Result<(), ()> { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + let mut turn_state = active_turn.turn_state.lock().await; + if !input.is_empty() { + let pending_input = &mut turn_state.pending_input; + input.append(&mut pending_input.items); + pending_input.items = input; + } + Ok(()) + } + None => Err(()), + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub(crate) async fn get_pending_input( + &self, + active_turn: &Mutex>, + ) -> Vec { + let (pending_input, accepts_mailbox_delivery) = { + let mut active = active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + let mut turn_state = active_turn.turn_state.lock().await; + ( + turn_state.pending_input.items.split_off(0), + turn_state.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (Vec::new(), true), + } + }; + if !accepts_mailbox_delivery { + return pending_input; + } + let mailbox_items = self.drain_mailbox_input_items().await; + if pending_input.is_empty() { + mailbox_items + } else if mailbox_items.is_empty() { + pending_input + } else { + let mut pending_input = pending_input; + pending_input.extend(mailbox_items); + pending_input + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state reads must remain atomic" + )] + pub(crate) async fn has_pending_input(&self, active_turn: &Mutex>) -> bool { + let (has_turn_pending_input, accepts_mailbox_delivery) = { + let active = active_turn.lock().await; + match active.as_ref() { + Some(active_turn) => { + let turn_state = active_turn.turn_state.lock().await; + ( + !turn_state.pending_input.items.is_empty(), + turn_state.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (false, true), + } + }; + if has_turn_pending_input { + return true; + } + if !accepts_mailbox_delivery { + return false; + } + self.has_pending_mailbox_items().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::AgentPath; + use pretty_assertions::assert_eq; + + fn make_mail( + author: AgentPath, + recipient: AgentPath, + content: &str, + trigger_turn: bool, + ) -> InterAgentCommunication { + InterAgentCommunication::new( + author, + recipient, + Vec::new(), + content.to_string(), + trigger_turn, + ) + } + + #[tokio::test] + async fn input_queue_notifies_mailbox_subscribers() { + let input_queue = InputQueue::new(); + let mut mailbox_rx = input_queue.subscribe_mailbox().await; + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + )) + .await; + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "two", + /*trigger_turn*/ false, + )) + .await; + + mailbox_rx.changed().await.expect("mailbox update"); + } + + #[tokio::test] + async fn input_queue_drains_mailbox_in_delivery_order() { + let input_queue = InputQueue::new(); + let mail_one = make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + ); + let mail_two = make_mail( + AgentPath::try_from("/root/worker").expect("agent path"), + AgentPath::root(), + "two", + /*trigger_turn*/ false, + ); + + input_queue + .enqueue_mailbox_communication(mail_one.clone()) + .await; + input_queue + .enqueue_mailbox_communication(mail_two.clone()) + .await; + + assert_eq!( + input_queue.drain_mailbox_input_items().await, + vec![ + mail_one.to_response_input_item(), + mail_two.to_response_input_item() + ] + ); + assert!(!input_queue.has_pending_mailbox_items().await); + } + + #[tokio::test] + async fn input_queue_tracks_pending_trigger_turn_mail() { + let input_queue = InputQueue::new(); + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "queued", + /*trigger_turn*/ false, + )) + .await; + assert!(!input_queue.has_trigger_turn_mailbox_items().await); + + input_queue + .enqueue_mailbox_communication(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "wake", + /*trigger_turn*/ true, + )) + .await; + assert!(input_queue.has_trigger_turn_mailbox_items().await); + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 02d9e75d4d..4db6c97e4b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -10,8 +10,6 @@ use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; -use crate::agent::Mailbox; -use crate::agent::MailboxReceiver; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; use crate::attestation::AttestationProvider; @@ -193,6 +191,7 @@ use codex_protocol::exec_output::StreamOutput; mod config_lock; mod handlers; +mod input_queue; mod mcp; mod multi_agents; mod review; @@ -206,6 +205,7 @@ use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] use self::handlers::submission_dispatch_span; use self::handlers::submission_loop; +pub(crate) use self::input_queue::TurnInputQueue; use self::review::spawn_review_thread; use self::session::AppServerClientMetadata; use self::session::Session; @@ -289,8 +289,6 @@ use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; use crate::shell_snapshot::ShellSnapshot; -use crate::state::ActiveTurn; -use crate::state::MailboxDeliveryPhase; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; use crate::state::SessionState; @@ -3163,195 +3161,36 @@ impl Session { .set_responsesapi_client_metadata(responsesapi_client_metadata); } - let mut turn_state = active_turn.turn_state.lock().await; - turn_state.push_pending_input(input.into()); - turn_state.accept_mailbox_delivery_for_current_turn(); + self.input_queue + .push_pending_input_and_accept_mailbox_delivery_for_turn_state( + active_turn.turn_state.as_ref(), + input.into(), + ) + .await; Ok(active_turn_id.clone()) } /// Returns the input if there was no task running to inject into. - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] pub async fn inject_response_items( &self, input: Vec, ) -> Result<(), Vec> { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - for item in input { - ts.push_pending_input(item); - } - Ok(()) - } - None => Err(input), - } - } - - pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; - let Some(turn_state) = turn_state else { - return; - }; - let mut turn_state = turn_state.lock().await; - if turn_state.has_pending_input() { - return; - } - turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn); - } - - pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; - let Some(turn_state) = turn_state else { - return; - }; - turn_state - .lock() + self.input_queue + .inject_response_items(&self.active_turn, input) .await - .set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn); } pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) { - let turn_state = self.turn_state_for_sub_id(sub_id).await; + let turn_state = self + .input_queue + .turn_state_for_sub_id(&self.active_turn, sub_id) + .await; let Some(turn_state) = turn_state else { return; }; turn_state.lock().await.has_memory_citation = true; } - async fn turn_state_for_sub_id( - &self, - sub_id: &str, - ) -> Option>> { - let active = self.active_turn.lock().await; - active.as_ref().and_then(|active_turn| { - active_turn - .tasks - .contains_key(sub_id) - .then(|| Arc::clone(&active_turn.turn_state)) - }) - } - - pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver { - self.mailbox.subscribe() - } - - pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) { - self.mailbox.send(communication); - } - - pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool { - self.mailbox_rx.lock().await.has_pending_trigger_turn() - } - - pub(crate) async fn has_pending_mailbox_items(&self) -> bool { - self.mailbox_rx.lock().await.has_pending() - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub async fn prepend_pending_input(&self, input: Vec) -> Result<(), ()> { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.prepend_pending_input(input); - Ok(()) - } - None => Err(()), - } - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub async fn get_pending_input(&self) -> Vec { - let (pending_input, accepts_mailbox_delivery) = { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ( - ts.take_pending_input(), - ts.accepts_mailbox_delivery_for_current_turn(), - ) - } - None => (Vec::new(), true), - } - }; - if !accepts_mailbox_delivery { - return pending_input; - } - let mailbox_items = { - let mut mailbox_rx = self.mailbox_rx.lock().await; - mailbox_rx - .drain() - .into_iter() - .map(|mail| mail.to_response_input_item()) - .collect::>() - }; - if pending_input.is_empty() { - mailbox_items - } else if mailbox_items.is_empty() { - pending_input - } else { - let mut pending_input = pending_input; - pending_input.extend(mailbox_items); - pending_input - } - } - - /// Queue response items to be injected into the next active turn created for this session. - pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { - if items.is_empty() { - return; - } - - let mut idle_pending_input = self.idle_pending_input.lock().await; - idle_pending_input.extend(items); - } - - pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { - std::mem::take(&mut *self.idle_pending_input.lock().await) - } - - pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { - !self.idle_pending_input.lock().await.is_empty() - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state reads must remain atomic" - )] - pub async fn has_pending_input(&self) -> bool { - let (has_turn_pending_input, accepts_mailbox_delivery) = { - let active = self.active_turn.lock().await; - match active.as_ref() { - Some(at) => { - let ts = at.turn_state.lock().await; - ( - ts.has_pending_input(), - ts.accepts_mailbox_delivery_for_current_turn(), - ) - } - None => (false, true), - } - }; - if has_turn_pending_input { - return true; - } - if !accepts_mailbox_delivery { - return false; - } - self.has_pending_mailbox_items().await - } - pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); let had_active_turn = self.active_turn.lock().await.is_some(); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 925ee2df59..082308f20d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,7 @@ +use super::input_queue::InputQueue; use super::*; use crate::goals::GoalRuntimeState; +use crate::state::ActiveTurn; use codex_protocol::SessionId; use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; @@ -27,9 +29,7 @@ pub(crate) struct Session { pub(super) pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, - pub(super) mailbox: Mailbox, - pub(super) mailbox_rx: Mutex, - pub(super) idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! + pub(crate) input_queue: InputQueue, pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, @@ -950,7 +950,6 @@ impl Session { let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = watch::channel(false); - let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { conversation_id: thread_id, installation_id, @@ -963,9 +962,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: InputQueue::new(), goal_runtime: GoalRuntimeState::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, @@ -1130,7 +1127,7 @@ impl Session { }; // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. - sess.record_initial_history(initial_history).await; + Box::pin(sess.record_initial_history(initial_history)).await; { let mut state = sess.state.lock().await; state.set_pending_session_start_source(Some(session_start_source)); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e3f038eff7..4486d287c5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4394,7 +4394,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*goal_tools_supported*/ true, ); - let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), @@ -4407,9 +4406,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: super::input_queue::InputQueue::new(), goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -6252,7 +6249,6 @@ where /*goal_tools_supported*/ true, )); - let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), @@ -6265,9 +6261,7 @@ where pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), - mailbox, - mailbox_rx: Mutex::new(mailbox_rx), - idle_pending_input: Mutex::new(Vec::new()), + input_queue: super::input_queue::InputQueue::new(), goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -8098,7 +8092,7 @@ async fn steer_input_returns_active_turn_id() { .expect("steering with matching expected turn id should succeed"); assert_eq!(turn_id, tc.sub_id); - assert!(sess.has_pending_input().await); + assert!(sess.input_queue.has_pending_input(&sess.active_turn).await); } #[tokio::test] @@ -8144,7 +8138,7 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { .await .expect("inject initial pending input into active turn"); - let drained = sess.get_pending_input().await; + let drained = sess.input_queue.get_pending_input(&sess.active_turn).await; assert_eq!(drained, vec![blocked, later.clone()]); sess.inject_response_items(vec![newer.clone()]) @@ -8153,11 +8147,15 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { let mut drained_iter = drained.into_iter(); let _blocked = drained_iter.next().expect("blocked prompt should exist"); - sess.prepend_pending_input(drained_iter.collect()) + sess.input_queue + .prepend_pending_input(&sess.active_turn, drained_iter.collect()) .await .expect("requeue later pending input at the front of the queue"); - assert_eq!(sess.get_pending_input().await, vec![later, newer]); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![later, newer] + ); } #[tokio::test] @@ -8171,7 +8169,8 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { phase: None, }; - sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) + sess.input_queue + .queue_response_items_for_next_turn(vec![queued_item.clone()]) .await; sess.spawn_task( @@ -8184,7 +8183,10 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { ) .await; - assert_eq!(sess.get_pending_input().await, vec![queued_item]); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![queued_item] + ); } #[tokio::test] @@ -8198,13 +8200,18 @@ async fn idle_interrupt_does_not_wake_queued_next_turn_items() { phase: None, }; - sess.queue_response_items_for_next_turn(vec![queued_item]) + sess.input_queue + .queue_response_items_for_next_turn(vec![queued_item]) .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; assert!(sess.active_turn.lock().await.is_none()); - assert!(sess.has_queued_response_items_for_next_turn().await); + assert!( + sess.input_queue + .has_queued_response_items_for_next_turn() + .await + ); } #[tokio::test] @@ -8222,16 +8229,17 @@ async fn abort_empty_active_turn_preserves_pending_input() { let active_turn = active.get_or_insert_with(ActiveTurn::default); Arc::clone(&active_turn.turn_state) }; - turn_state - .lock() - .await - .push_pending_input(pending_item.clone()); + sess.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), vec![pending_item.clone()]) + .await; sess.abort_all_tasks(TurnAbortReason::Replaced).await; assert!(sess.active_turn.lock().await.is_none()); assert_eq!( - turn_state.lock().await.take_pending_input(), + sess.input_queue + .take_pending_input_for_turn_state(turn_state.as_ref()) + .await, vec![pending_item] ); } @@ -8593,7 +8601,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh }) .await?; - let pending_input = sess.get_pending_input().await; + let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; @@ -8814,7 +8822,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { }) .await?; - let pending_input = sess.get_pending_input().await; + let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; assert!( pending_input.iter().any(|item| { matches!( @@ -9021,19 +9029,26 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; assert!( - !sess.has_pending_input().await, + !sess.input_queue.has_pending_input(&sess.active_turn).await, "queue-only mailbox mail should stay buffered once the current turn emitted its answer" ); - assert_eq!(sess.get_pending_input().await, Vec::new()); + assert_eq!( + sess.input_queue.get_pending_input(&sess.active_turn).await, + Vec::new() + ); sess.abort_all_tasks(TurnAbortReason::Replaced).await; assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![communication.to_response_input_item()], ); } @@ -9051,23 +9066,27 @@ async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late trigger update".to_string(), - /*trigger_turn*/ true, - )); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late trigger update".to_string(), + /*trigger_turn*/ true, + )) + .await; assert!( - !sess.has_pending_input().await, + !sess.input_queue.has_pending_input(&sess.active_turn).await, "trigger-turn mailbox mail should not extend the current turn after its answer boundary" ); sess.abort_all_tasks(TurnAbortReason::Replaced).await; - assert!(sess.has_trigger_turn_mailbox_items().await); + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); } #[tokio::test] @@ -9090,8 +9109,12 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; sess.steer_input( vec![UserInput::Text { text: "follow up".to_string(), @@ -9104,7 +9127,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { .expect("steered input should be accepted"); assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ ResponseInputItem::from(vec![UserInput::Text { text: "follow up".to_string(), @@ -9135,8 +9158,12 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; sess.steer_input( vec![UserInput::Text { text: "follow up".to_string(), @@ -9148,10 +9175,12 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { .await .expect("steered input should be accepted"); - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ ResponseInputItem::from(vec![UserInput::Text { text: "follow up".to_string(), @@ -9182,8 +9211,12 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { ) .await; - sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; - sess.enqueue_mailbox_communication(communication.clone()); + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; let item = ResponseItem::FunctionCall { id: None, @@ -9207,7 +9240,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { assert!(output.needs_follow_up); assert!(output.tool_future.is_some()); assert_eq!( - sess.get_pending_input().await, + sess.input_queue.get_pending_input(&sess.active_turn).await, vec![communication.to_response_input_item()], ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index ab316440e5..0c5e00927a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -148,7 +148,7 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() && !sess.has_pending_input().await { + if input.is_empty() && !sess.input_queue.has_pending_input(&sess.active_turn).await { return None; } @@ -413,7 +413,7 @@ pub(crate) async fn run_turn( // submitted through the UI while the model was running. Though the UI // may support this, the model might not. let pending_input = if can_drain_pending_input { - sess.get_pending_input().await + sess.input_queue.get_pending_input(&sess.active_turn).await } else { Vec::new() }; @@ -434,7 +434,10 @@ pub(crate) async fn run_turn( } => { let remaining_pending_input = pending_input_iter.collect::>(); if !remaining_pending_input.is_empty() { - let _ = sess.prepend_pending_input(remaining_pending_input).await; + let _ = sess + .input_queue + .prepend_pending_input(&sess.active_turn, remaining_pending_input) + .await; requeued_pending_input = true; } blocked_pending_input_contexts = additional_contexts; @@ -494,7 +497,7 @@ pub(crate) async fn run_turn( last_agent_message: sampling_request_last_agent_message, } = sampling_request_output; can_drain_pending_input = true; - let has_pending_input = sess.has_pending_input().await; + let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await; let needs_follow_up = model_needs_follow_up || has_pending_input; let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= auto_compact_limit; @@ -2065,7 +2068,7 @@ async fn try_run_sampling_request( } needs_follow_up |= output_result.needs_follow_up; // todo: remove before stabilizing multi-agent v2 - if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() { + if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await { break Ok(SamplingRequestResult { needs_follow_up: true, last_agent_message, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 70aae74e87..86438ad56a 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -11,7 +11,6 @@ use tokio_util::task::AbortOnDropHandle; use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; -use codex_protocol::models::ResponseInputItem; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -20,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::RequestId; use tokio::sync::oneshot; +use crate::session::TurnInputQueue; use crate::session::turn_context::TurnContext; use crate::tasks::AnySessionTask; use codex_protocol::models::AdditionalPermissionProfile; @@ -115,7 +115,7 @@ pub(crate) struct TurnState { pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, - pending_input: Vec, + pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, granted_permissions: Option, strict_auto_review_enabled: bool, @@ -146,13 +146,12 @@ impl TurnState { self.pending_approvals.remove(key) } - pub(crate) fn clear_pending(&mut self) { + pub(crate) fn clear_pending_waiters(&mut self) { self.pending_approvals.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); - self.pending_input.clear(); } pub(crate) fn insert_pending_request_permissions( @@ -220,33 +219,6 @@ impl TurnState { self.pending_dynamic_tools.remove(key) } - pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { - self.pending_input.push(input); - } - - pub(crate) fn prepend_pending_input(&mut self, mut input: Vec) { - if input.is_empty() { - return; - } - - input.append(&mut self.pending_input); - self.pending_input = input; - } - - pub(crate) fn take_pending_input(&mut self) -> Vec { - if self.pending_input.is_empty() { - Vec::with_capacity(0) - } else { - let mut ret = Vec::new(); - std::mem::swap(&mut ret, &mut self.pending_input); - ret - } - } - - pub(crate) fn has_pending_input(&self) -> bool { - !self.pending_input.is_empty() - } - pub(crate) fn accept_mailbox_delivery_for_current_turn(&mut self) { self.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn); } @@ -276,11 +248,3 @@ impl TurnState { self.strict_auto_review_enabled } } - -impl ActiveTurn { - /// Clear any pending approvals and input buffered for the current turn. - pub(crate) async fn clear_pending(&self) { - let mut ts = self.turn_state.lock().await; - ts.clear_pending(); - } -} diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bc44fe0c3b..0e32ccb9fb 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -158,7 +158,8 @@ pub(crate) async fn record_completed_response_item_with_finalized_facts( |facts| facts.defers_mailbox_delivery_to_next_turn, ); if defers_mailbox_delivery { - sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id) + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &turn_context.sub_id) .await; } mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await; @@ -351,7 +352,11 @@ pub(crate) async fn handle_output_item_done( // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. Ok(Some(call)) => { ctx.sess - .accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id) + .input_queue + .accept_mailbox_delivery_for_current_turn( + &ctx.sess.active_turn, + &ctx.turn_context.sub_id, + ) .await; let payload_preview = call.payload.log_payload().into_owned(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 18f650e925..ba7f707927 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -347,24 +347,23 @@ impl Session { { warn!("failed to apply goal runtime turn-start event: {err}"); } - let queued_response_items = self.take_queued_response_items_for_next_turn().await; - let mailbox_items = self.get_pending_input().await; + let queued_response_items = self + .input_queue + .take_queued_response_items_for_next_turn() + .await; + let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); debug_assert!(turn.tasks.is_empty()); Arc::clone(&turn.turn_state) }; - { - let mut turn_state = turn_state.lock().await; - turn_state.token_usage_at_turn_start = token_usage_at_turn_start; - for item in queued_response_items { - turn_state.push_pending_input(item); - } - for item in mailbox_items { - turn_state.push_pending_input(item); - } - } + turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; + let mut pending_items = queued_response_items; + pending_items.extend(mailbox_items); + self.input_queue + .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) + .await; self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()) .await; @@ -468,8 +467,11 @@ impl Session { self: &Arc, sub_id: String, ) { - if !self.has_queued_response_items_for_next_turn().await - && !self.has_trigger_turn_mailbox_items().await + if !self + .input_queue + .has_queued_response_items_for_next_turn() + .await + && !self.input_queue.has_trigger_turn_mailbox_items().await { return; } @@ -521,7 +523,7 @@ impl Session { if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. - active_turn.clear_pending().await; + self.input_queue.clear_pending(&active_turn).await; } if reason == TurnAbortReason::Interrupted && aborted_turn { self.maybe_start_turn_for_pending_work().await; @@ -567,7 +569,7 @@ impl Session { } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. - active_turn.clear_pending().await; + self.input_queue.clear_pending(&active_turn).await; if reason == TurnAbortReason::Interrupted { self.maybe_start_turn_for_pending_work().await; @@ -609,8 +611,11 @@ impl Session { } }; if let Some(turn_state) = turn_state.as_ref() { - let mut ts = turn_state.lock().await; - pending_input = ts.take_pending_input(); + pending_input = self + .input_queue + .take_pending_input_for_turn_state(turn_state.as_ref()) + .await; + let ts = turn_state.lock().await; turn_had_memory_citation = ts.has_memory_citation; turn_tool_calls = ts.tool_calls; token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone()); diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 756a691f11..50414df278 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -80,7 +80,7 @@ impl SessionTask for RegularTask { ) .instrument(run_turn_span.clone()) .await; - if !sess.has_pending_input().await { + if !sess.input_queue.has_pending_input(&sess.active_turn).await { return last_agent_message; } next_input = Vec::new(); 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 0044bf8522..a83167ffc5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2791,13 +2791,16 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "hello from worker".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "hello from worker".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await @@ -3276,13 +3279,16 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "completed".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "completed".to_string(), + /*trigger_turn*/ false, + )) + .await; let wait_output = wait_task .await @@ -3346,13 +3352,16 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .agent_path .expect("worker path"); - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "already queued".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "already queued".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = timeout( Duration::from_millis(500), @@ -3442,13 +3451,16 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_b_path, - AgentPath::root(), - Vec::new(), - "from worker b".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_b_path, + AgentPath::root(), + Vec::new(), + "from worker b".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await @@ -3527,13 +3539,16 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { }); tokio::task::yield_now().await; - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "sensitive child output".to_string(), - /*trigger_turn*/ false, - )); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "sensitive child output".to_string(), + /*trigger_turn*/ false, + )) + .await; let output = wait_task .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 8913411b53..285abd9330 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -60,7 +60,7 @@ impl ToolExecutor for Handler { None => default_timeout_ms, }; - let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); + let mut mailbox_rx = session.input_queue.subscribe_mailbox().await; session .send_event( @@ -76,12 +76,8 @@ impl ToolExecutor for Handler { ) .await; - let timed_out = if session.has_pending_mailbox_items().await { - false - } else { - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); - !wait_for_mailbox_change(&mut mailbox_seq_rx, deadline).await - }; + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + let timed_out = !wait_for_mailbox_change(&mut mailbox_rx, deadline).await; let result = WaitAgentResult::from_timed_out(timed_out); session @@ -153,10 +149,10 @@ impl ToolOutput for WaitAgentResult { } async fn wait_for_mailbox_change( - mailbox_seq_rx: &mut tokio::sync::watch::Receiver, + mailbox_rx: &mut tokio::sync::watch::Receiver<()>, deadline: Instant, ) -> bool { - match timeout_at(deadline, mailbox_seq_rx.changed()).await { + match timeout_at(deadline, mailbox_rx.changed()).await { Ok(Ok(())) => true, Ok(Err(_)) | Err(_) => false, } From a66712c95d63ab4fbd6f2faf67df4ba99b4485e3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 18 May 2026 20:24:09 -0300 Subject: [PATCH 084/114] fix(tui): warn on unsupported iTerm2 pet versions (#23371) ## Why Older iTerm2 builds can be detected as supporting the image transport that terminal pets use, but in practice they fail to render the pet flow correctly. Instead of silently attempting image rendering, Codex should tell the user that their iTerm2 version is too old and that upgrading is the fix. ## What Changed - gate iTerm2 pet auto-detection on version `3.6.0` or newer - show a dedicated upgrade message for older or unknown iTerm2 versions instead of the generic unsupported-terminal warning - keep the existing generic unsupported-terminal path for non-iTerm terminals - add regression coverage for iTerm2 version parsing and the old-iTerm warning path ## How to Test 1. Start Codex in iTerm2 3.6 or newer. 2. Run `/pets`. 3. Confirm the pets picker opens instead of showing a warning. 4. Start Codex in an older iTerm2 build, or exercise the equivalent test path. 5. Run `/pets`. 6. Confirm Codex warns that pets require iTerm2 3.6 or newer and tells the user to upgrade. 7. Also verify that a non-iTerm unsupported terminal still shows the generic unsupported-terminal message. Targeted tests: - `cargo test -p codex-terminal-detection` - `cargo test -p codex-tui pets::` - `cargo test -p codex-tui slash_pets_on_unsupported_terminal` - `cargo test -p codex-tui slash_pets_on_old_iterm2` --- .../src/chatwidget/tests/slash_commands.rs | 50 ++++++++ codex-rs/tui/src/pets/image_protocol.rs | 112 +++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e4865e64f3..c8c0f60584 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -15,6 +15,18 @@ fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) { )); } +fn force_terminal_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Terminal, + )); +} + +fn force_old_iterm2_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Iterm2TooOld, + )); +} + fn fast_tier_command() -> ServiceTierCommand { ServiceTierCommand { id: ServiceTier::Fast.request_value().to_string(), @@ -1934,6 +1946,44 @@ async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +#[serial] +async fn slash_pets_on_unsupported_terminal_shows_terminal_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_terminal_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets aren’t available in this terminal.")); + assert!(rendered.contains("Kitty graphics or Sixel support")); +} + +#[tokio::test] +#[serial] +async fn slash_pets_on_old_iterm2_shows_upgrade_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_old_iterm2_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets require iTerm2 3.6 or newer.")); + assert!(rendered.contains("Upgrade iTerm2 to use terminal pets.")); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index cf241aee9f..06b6fc77bc 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -21,6 +21,7 @@ const ESC: &str = "\x1b"; const ST: &str = "\x1b\\"; const KITTY_CHUNK_SIZE: usize = 4096; const SIXEL_CACHE_VERSION: &str = "v2"; +const ITERM2_KITTY_MIN_VERSION: (u64, u64, u64) = (3, 6, 0); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImageProtocol { @@ -55,6 +56,7 @@ impl PetImageSupport { pub(crate) enum PetImageUnsupportedReason { Tmux, Zellij, + Iterm2TooOld, Terminal, } @@ -67,6 +69,9 @@ impl PetImageUnsupportedReason { Self::Zellij => { "Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets." } + Self::Iterm2TooOld => { + "Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets." + } Self::Terminal => { "Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux." } @@ -142,6 +147,10 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { return PetImageSupport::Supported(ImageProtocol::KittyLocalFile); } + if is_iterm2_terminal(info) { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld); + } + if supports_kitty_graphics(info) { return PetImageSupport::Supported(ImageProtocol::Kitty); } @@ -154,6 +163,14 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { } fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool { + is_iterm2_terminal(info) + && version_is_at_least( + info.version.as_deref(), + /*minimum*/ ITERM2_KITTY_MIN_VERSION, + ) +} + +fn is_iterm2_terminal(info: &TerminalInfo) -> bool { matches!(info.name, TerminalName::Iterm2) || terminal_field_contains(info.term_program.as_deref(), "iterm") } @@ -181,6 +198,24 @@ fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool { value.is_some_and(|value| value.to_ascii_lowercase().contains(needle)) } +fn version_is_at_least(version: Option<&str>, minimum: (u64, u64, u64)) -> bool { + parse_dotted_version(version).is_some_and(|version| version >= minimum) +} + +fn parse_dotted_version(version: Option<&str>) -> Option<(u64, u64, u64)> { + let version = version?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + + if parts.next().is_some() { + return None; + } + + Some((major, minor, patch)) +} + pub fn kitty_delete_image(image_id: u32) -> String { wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}")) } @@ -400,16 +435,18 @@ mod tests { #[test] fn pet_image_support_detects_iterm2_kitty_file_graphics() { for info in [ - terminal_info_for_test( + terminal_info_with_version_for_test( TerminalName::Iterm2, /*multiplexer*/ None, Some("iTerm.app"), + Some("3.6.10"), /*term*/ None, ), - terminal_info_for_test( + terminal_info_with_version_for_test( TerminalName::Unknown, /*multiplexer*/ None, Some("iTerm.app"), + Some("3.6.10"), Some("xterm-256color"), ), ] { @@ -420,6 +457,49 @@ mod tests { } } + #[test] + fn pet_image_support_rejects_old_iterm2_versions() { + for info in [ + terminal_info_with_version_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("3.5.14"), + /*term*/ None, + ), + terminal_info_with_version_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + Some("iTerm.app"), + /*version*/ None, + Some("xterm-256color"), + ), + terminal_info_with_version_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("3.5"), + /*term*/ None, + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld) + ); + } + } + + #[test] + fn pet_image_support_old_iterm2_message_mentions_upgrade() { + let message = PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld) + .unsupported_message(); + + assert_eq!( + message, + Some("Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets.") + ); + } + #[test] fn pet_image_support_detects_kitty_graphics_terminals() { for info in [ @@ -538,16 +618,42 @@ mod tests { multiplexer: Option, term_program: Option<&str>, term: Option<&str>, + ) -> TerminalInfo { + terminal_info_with_version_for_test( + name, + multiplexer, + term_program, + /*version*/ None, + term, + ) + } + + fn terminal_info_with_version_for_test( + name: TerminalName, + multiplexer: Option, + term_program: Option<&str>, + version: Option<&str>, + term: Option<&str>, ) -> TerminalInfo { TerminalInfo { name, term_program: term_program.map(str::to_string), - version: /*version*/ None, + version: version.map(str::to_string), term: term.map(str::to_string), multiplexer, } } + #[test] + fn parse_dotted_version_requires_simple_numeric_components() { + assert_eq!(parse_dotted_version(Some("3.6.10")), Some((3, 6, 10))); + assert_eq!(parse_dotted_version(Some("3.6")), Some((3, 6, 0))); + assert_eq!(parse_dotted_version(Some("3")), Some((3, 0, 0))); + assert_eq!(parse_dotted_version(Some("3.6.10.1")), None); + assert_eq!(parse_dotted_version(Some("3.6beta")), None); + assert_eq!(parse_dotted_version(/*version*/ None), None); + } + #[test] fn sixel_frame_encodes_without_external_crate() { let dir = tempfile::tempdir().unwrap(); From 5696167fe86fd723694521acdacd7223de2ea8cf Mon Sep 17 00:00:00 2001 From: marksteinbrick-oai Date: Mon, 18 May 2026 17:13:49 -0700 Subject: [PATCH 085/114] [codex-analytics] preserve user thread source for exec threads (#23376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why - Follows #20949. - The above moved `thread_source` attribution from the reducer to explicit caller provided metadata - The `codex exec` path still omitted this metadata, leaving exec-created threads without `thread_source` ## What Changed - Ensures exec threads are marked as user created (`thread_source = "user"`) - Preserves thread-source metadata in exec’s startup session event ## Verification - Updated unit tests to validate exec `thread_source` propagation. - `cargo +1.93.0 test -p codex-exec --manifest-path codex-rs/Cargo.toml` - `cargo +1.93.1 build -p codex-cli --manifest-path codex-rs/Cargo.toml` - Validated locally with a freshly built `codex exec` run: - Startup logs showed `thread_source: Some(User)`. - Rollout metadata recorded `"thread_source":"user"`. --- codex-rs/exec/src/lib.rs | 7 +++++- codex-rs/exec/src/lib_tests.rs | 42 +++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 3e2cf21051..761625856e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -974,6 +975,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), + thread_source: Some(ThreadSource::User), ..ThreadStartParams::default() } } @@ -1082,6 +1084,7 @@ fn session_configured_from_thread_start_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -1103,6 +1106,7 @@ fn session_configured_from_thread_resume_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -1133,6 +1137,7 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { fn session_configured_from_thread_response( session_id: &str, thread_id: &str, + thread_source: Option, thread_name: Option, rollout_path: Option, model: String, @@ -1154,7 +1159,7 @@ fn session_configured_from_thread_response( session_id, thread_id, forked_from_id: None, - thread_source: None, + thread_source, thread_name, model, model_provider_id, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 92dc0678e5..2f04b2d260 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -458,6 +458,25 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } +#[tokio::test] +async fn thread_start_params_include_user_thread_source() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + + let params = thread_start_params_from_config(&config); + + assert_eq!( + params.thread_source, + Some(codex_app_server_protocol::ThreadSource::User) + ); +} + #[test] fn active_profile_selection_uses_profile_id_only() { let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( @@ -548,6 +567,27 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_co ); } +#[tokio::test] +async fn session_configured_from_thread_response_preserves_thread_source() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let response = sample_thread_start_response(); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!( + event.thread_source, + Some(codex_protocol::protocol::ThreadSource::User) + ); +} + fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { @@ -564,7 +604,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, - thread_source: None, + thread_source: Some(codex_app_server_protocol::ThreadSource::User), agent_nickname: None, agent_role: None, git_info: None, From 3fd79b7986b8019acb35a8e3a28ae32b67ca31ac Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 18 May 2026 17:28:50 -0700 Subject: [PATCH 086/114] app-server: use profile ids in v2 permission params (#23360) ## Why The v2 app-server permission profile fields are experimental, but the previous migration kept a legacy object payload for profile selection. That made clients aware of server-owned `activePermissionProfile` metadata such as `extends`, and it kept a `legacy_additional_writable_roots` path even though `runtimeWorkspaceRoots` now owns runtime workspace-root selection. This PR makes the client contract match the intended model: clients select a permission profile by id, and the server resolves and reports active profile provenance in response payloads. Follow-up to #22611. ## What Changed - Changed `thread/start`, `thread/resume`, `thread/fork`, and `turn/start` permission profile selection to plain profile id strings. - Changed `command/exec.permissionProfile` to a plain profile id string for the same client/server ownership split. - Removed `PermissionProfileSelectionParams` and the legacy `{ type: "profile", modifications: [...] }` compatibility deserializer. - Updated app-server, TUI, and `codex exec` call sites to send only ids, while keeping `activePermissionProfile` as server response metadata. - Updated app-server docs and schema fixtures for the revised `command/exec.permissionProfile` shape. ## Verification - `cargo test -p codex-app-server-protocol` - `RUST_MIN_STACK=8388608 cargo test -p codex-app-server` - `cargo test -p codex-exec` - `RUST_MIN_STACK=8388608 cargo test -p codex-tui` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23360). * #23368 * __->__ #23360 --- .../schema/json/ClientRequest.json | 20 ---- .../schema/json/v2/CommandExecParams.json | 20 ---- codex-rs/app-server-protocol/src/export.rs | 16 +-- .../src/protocol/common.rs | 3 +- .../src/protocol/v2/command_exec.rs | 5 +- .../src/protocol/v2/permissions.rs | 98 ------------------- .../src/protocol/v2/tests.rs | 77 +++++++-------- .../src/protocol/v2/thread.rs | 13 +-- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 1 - .../command_exec_processor.rs | 14 +-- .../request_processors/thread_processor.rs | 9 +- .../src/request_processors/thread_summary.rs | 23 ----- .../thread_summary_tests.rs | 40 -------- .../src/request_processors/turn_processor.rs | 7 +- .../app-server/tests/suite/v2/command_exec.rs | 12 +-- .../app-server/tests/suite/v2/turn_start.rs | 8 +- codex-rs/exec/src/lib.rs | 11 +-- codex-rs/exec/src/lib_tests.rs | 7 +- codex-rs/tui/src/app_server_session.rs | 25 ++--- 21 files changed, 79 insertions(+), 337 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index b976c9e4ef..2621a0bd1e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,26 +5,6 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "ActivePermissionProfile": { - "properties": { - "extends": { - "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index b6e939386b..d00a0b60e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,26 +5,6 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "ActivePermissionProfile": { - "properties": { - "extends": { - "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index d44b67825d..fcaa8a1eb5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2747,7 +2747,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k let _guard = TempDirGuard(output_dir.clone()); let path = output_dir.join("CommandExecParams.ts"); let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -import type { ActivePermissionProfile } from "./ActivePermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; export type CommandExecParams = {/** @@ -2770,12 +2769,12 @@ size?: CommandExecTerminalSize | null, /** */ sandboxPolicy?: SandboxPolicy | null, /** - * Optional active permissions profile for this command. + * Optional active permissions profile id for this command. * * Defaults to the user's configured permissions when omitted. Cannot be * combined with `sandboxPolicy`. */ -permissionProfile?: ActivePermissionProfile | null}; +permissionProfile?: string | null}; "#; fs::write(&path, content)?; @@ -2788,16 +2787,7 @@ permissionProfile?: ActivePermissionProfile | null}; filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?; let filtered = fs::read_to_string(&path)?; - assert_eq!( - filtered.contains("permissionProfile?: ActivePermissionProfile"), - false - ); - assert_eq!( - filtered.contains( - r#"import type { ActivePermissionProfile } from "./ActivePermissionProfile";"# - ), - false - ); + assert_eq!(filtered.contains("permissionProfile?: string"), false); assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); assert_eq!( filtered.contains(r#"import type { SandboxPolicy } from "./SandboxPolicy";"#), diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4e6cef308e..7a7fe5642c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1555,6 +1555,7 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; @@ -2994,7 +2995,7 @@ mod tests { env: None, size: None, sandbox_policy: None, - permission_profile: Some(v2::ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index d476dcb90e..8f913b5bcf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -1,4 +1,3 @@ -use super::ActivePermissionProfile; use super::SandboxPolicy; use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; @@ -100,13 +99,13 @@ pub struct CommandExecParams { /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Optional active permissions profile for this command. + /// Optional active permissions profile id for this command. /// /// Defaults to the user's configured permissions when omitted. Cannot be /// combined with `sandboxPolicy`. #[experimental("command/exec.permissionProfile")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permission_profile: Option, } /// Final buffered result for `command/exec`. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 3299695497..f00bcfaefb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -18,9 +18,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; -use serde::Deserializer; use serde::Serialize; -use serde::Serializer; use std::num::NonZeroUsize; use std::path::PathBuf; use ts_rs::TS; @@ -333,102 +331,6 @@ impl From for CoreActivePermissionProfile { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PermissionProfileSelectionParams { - id: String, - legacy_additional_writable_roots: Vec, -} - -impl PermissionProfileSelectionParams { - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - legacy_additional_writable_roots: Vec::new(), - } - } - - pub fn id(&self) -> &str { - &self.id - } - - pub fn into_id(self) -> String { - self.id - } - - pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] { - &self.legacy_additional_writable_roots - } -} - -impl From for PermissionProfileSelectionParams { - fn from(id: String) -> Self { - Self::new(id) - } -} - -impl Serialize for PermissionProfileSelectionParams { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.id) - } -} - -impl<'de> Deserialize<'de> for PermissionProfileSelectionParams { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum Wire { - Id(String), - LegacyProfile { - #[serde(rename = "type")] - _type: LegacyPermissionProfileSelectionType, - id: String, - #[serde(default)] - modifications: Option>, - }, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - enum LegacyPermissionProfileSelectionType { - Profile, - } - - #[derive(Deserialize)] - #[serde(tag = "type", rename_all = "camelCase")] - enum LegacyPermissionProfileModificationParams { - #[serde(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, - } - - match Wire::deserialize(deserializer)? { - Wire::Id(id) => Ok(Self::new(id)), - Wire::LegacyProfile { - id, modifications, .. - } => { - let legacy_additional_writable_roots = modifications - .unwrap_or_default() - .into_iter() - .map(|modification| match modification { - LegacyPermissionProfileModificationParams::AdditionalWritableRoot { - path, - } => path, - }) - .collect(); - Ok(Self { - id, - legacy_additional_writable_roots, - }) - } - } - } -} - #[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/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 40ec247338..b445fc4d01 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -14,6 +14,7 @@ use codex_protocol::mcp::CallToolResult; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; @@ -614,57 +615,49 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { } #[test] -fn permission_profile_selection_accepts_legacy_object_shape() { - let additional_root = absolute_path("additional-root"); - let params = json!({ - "permissions": { - "type": "profile", - "id": ":workspace", - "modifications": [ - { - "type": "additionalWritableRoot", - "path": additional_root, - } - ], - }, - }); - - let start: ThreadStartParams = - serde_json::from_value(params.clone()).expect("thread/start params deserialize"); - assert_legacy_permission_profile_selection(start.permissions, &additional_root); - - let resume: ThreadResumeParams = serde_json::from_value(json!({ - "threadId": "thread-1", - "permissions": params["permissions"].clone(), +fn permission_profile_selection_uses_id_string() { + let start: ThreadStartParams = serde_json::from_value(json!({ + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, })) - .expect("thread/resume params deserialize"); - assert_legacy_permission_profile_selection(resume.permissions, &additional_root); - - let fork: ThreadForkParams = serde_json::from_value(json!({ - "threadId": "thread-1", - "permissions": params["permissions"].clone(), - })) - .expect("thread/fork params deserialize"); - assert_legacy_permission_profile_selection(fork.permissions, &additional_root); + .expect("thread/start params deserialize"); + assert_eq!( + start.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); let turn: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread-1", "input": [], - "permissions": params["permissions"].clone(), + "permissions": "dev", })) .expect("turn/start params deserialize"); - assert_legacy_permission_profile_selection(turn.permissions, &additional_root); -} + assert_eq!(turn.permissions, Some("dev".to_string())); -fn assert_legacy_permission_profile_selection( - selection: Option, - additional_root: &AbsolutePathBuf, -) { - let selection = selection.expect("permissions should be present"); - assert_eq!(selection.id(), ":workspace"); + let command: CommandExecParams = serde_json::from_value(json!({ + "command": ["echo", "hello"], + "permissionProfile": "dev", + })) + .expect("command/exec params deserialize"); + assert_eq!(command.permission_profile, Some("dev".to_string())); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/resume params deserialize"); assert_eq!( - selection.legacy_additional_writable_roots(), - std::slice::from_ref(additional_root) + resume.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/fork params deserialize"); + assert_eq!( + fork.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) ); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 53f942cf80..41994e59b6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,7 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -122,10 +121,8 @@ pub struct ThreadStartParams { pub sandbox: Option, /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -284,10 +281,8 @@ pub struct ThreadResumeParams { /// Named profile id for the resumed thread. Cannot be combined with /// `sandbox`. #[experimental("thread/resume.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -395,10 +390,8 @@ pub struct ThreadForkParams { /// Named profile id for the forked thread. Cannot be combined with /// `sandbox`. #[experimental("thread/fork.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] 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 8781dd2bd5..50660af1d7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -1,6 +1,5 @@ use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; @@ -85,10 +84,8 @@ pub struct TurnStartParams { /// Select a named permissions profile id for this turn and subsequent /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] - #[schemars(with = "Option")] - #[ts(type = "string | null")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f9cb7c344..e2978861de 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -927,7 +927,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "permissionProfile": { "id": ":workspace", "extends": null }, // optional; defaults to user config + "permissionProfile": ":workspace", // optional profile id; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index e941caca09..ba28840f50 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 2b07588c8a..1219eb17f9 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -117,13 +117,6 @@ impl CommandExecRequestProcessor { "`permissionProfile` cannot be combined with `sandboxPolicy`", )); } - let permission_profile = if let Some(active_permission_profile) = permission_profile { - Some(PermissionProfileSelectionParams::new( - active_permission_profile.id, - )) - } else { - None - }; if size.is_some() && !tty { return Err(invalid_params("command/exec size requires tty: true")); @@ -199,14 +192,11 @@ impl CommandExecRequestProcessor { network_proxy_permission_profile, managed_network_requirements_enabled, ) = if let Some(permission_profile) = permission_profile { - let mut overrides = ConfigOverrides { + let overrides = ConfigOverrides { cwd: Some(cwd.to_path_buf()), + default_permissions: Some(permission_profile), ..Default::default() }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permission_profile), - ); let config = self .config_manager .load_for_cwd( 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 21160950ab..dd2dd6a57e 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1239,17 +1239,18 @@ impl ThreadRequestProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permissions: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, ) -> ConfigOverrides { - let mut overrides = ConfigOverrides { + ConfigOverrides { model, model_provider, service_tier, cwd: cwd.map(PathBuf::from), workspace_roots: runtime_workspace_roots, + default_permissions: permissions, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -1261,9 +1262,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); - overrides + } } fn parse_environment_selections( 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 63ceeb55e2..fea49b1637 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -175,29 +175,6 @@ pub(super) fn thread_response_active_permission_profile( active_permission_profile.map(Into::into) } -pub(super) fn apply_permission_profile_selection_to_config_overrides( - overrides: &mut ConfigOverrides, - permissions: Option, -) { - let Some(selection) = permissions else { - return; - }; - overrides.default_permissions = Some(selection.id().to_string()); - if selection.legacy_additional_writable_roots().is_empty() { - return; - } - - let legacy_roots = selection - .legacy_additional_writable_roots() - .iter() - .map(AbsolutePathBuf::to_path_buf); - if let Some(workspace_roots) = overrides.workspace_roots.as_mut() { - workspace_roots.extend(legacy_roots); - } else { - overrides.additional_writable_roots.extend(legacy_roots); - } -} - pub(super) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index 699db34ed4..f8902e132d 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -66,43 +66,3 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { assert_eq!(summary, expected); Ok(()) } - -#[test] -fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> { - let root = if cfg!(windows) { - AbsolutePathBuf::try_from("C:\\workspace-extra")? - } else { - AbsolutePathBuf::try_from("/workspace-extra")? - }; - let selection = serde_json::from_value::(json!({ - "type": "profile", - "id": ":workspace", - "modifications": [ - { - "type": "additionalWritableRoot", - "path": root, - } - ], - }))?; - - let mut overrides = ConfigOverrides::default(); - apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone())); - assert_eq!( - overrides.default_permissions, - Some(":workspace".to_string()) - ); - assert_eq!( - overrides.additional_writable_roots, - vec![root.to_path_buf()] - ); - - let mut overrides = ConfigOverrides { - workspace_roots: Some(Vec::new()), - ..ConfigOverrides::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection)); - assert_eq!(overrides.additional_writable_roots, Vec::::new()); - assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()])); - - Ok(()) -} 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 54fc0383e4..55a4e3a838 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -425,7 +425,7 @@ impl TurnRequestProcessor { "turn/start permission selection missing thread snapshot", )); }; - let mut overrides = ConfigOverrides { + let overrides = ConfigOverrides { cwd: cwd.clone(), workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( || { @@ -436,14 +436,11 @@ impl TurnRequestProcessor { .collect() }, )), + default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); let config = self .config_manager .load_for_cwd( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index e4c573e72f..b4038299cf 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -5,7 +5,6 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; -use codex_app_server_protocol::ActivePermissionProfile; use codex_app_server_protocol::CommandExecOutputDeltaNotification; use codex_app_server_protocol::CommandExecOutputStream; use codex_app_server_protocol::CommandExecParams; @@ -18,6 +17,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; @@ -219,7 +219,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -270,7 +270,7 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::new("networked")), + permission_profile: Some("networked".to_string()), }) .await?; @@ -318,7 +318,7 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -376,7 +376,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(ActivePermissionProfile::new("command-cwd")), + permission_profile: Some("command-cwd".to_string()), }) .await?; @@ -423,7 +423,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result env: None, size: None, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: Some(ActivePermissionProfile::read_only()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 844d9ab1bb..ae74b3432f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -889,11 +889,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some( - BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS - .to_string() - .into(), - ), + permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) .await?; @@ -2147,7 +2143,7 @@ stream_max_retries = 0 text_elements: Vec::new(), }], runtime_workspace_roots: Some(vec![old_root]), - permissions: Some("dev".to_string().into()), + permissions: Some("dev".to_string()), ..Default::default() }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 761625856e..9e54a72abd 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; @@ -1009,17 +1008,15 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa } } -fn permissions_selection_from_config(config: &Config) -> Option { +fn permissions_selection_from_config(config: &Config) -> Option { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) } -fn permissions_selection_from_active_profile( - active: ActivePermissionProfile, -) -> PermissionProfileSelectionParams { - PermissionProfileSelectionParams::new(active.id) +fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String { + active.id } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 2f04b2d260..79d07a0b76 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -479,14 +479,11 @@ async fn thread_start_params_include_user_thread_source() { #[test] fn active_profile_selection_uses_profile_id_only() { - let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( + let selection = permission_profile_id_from_active_profile(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, )); - assert_eq!( - selection, - PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) - ); + assert_eq!(selection, BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()); } #[tokio::test] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2620f80d53..22e4776559 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; @@ -1182,10 +1181,8 @@ fn sandbox_mode_from_permission_profile( } } -fn permissions_selection_from_active_profile( - active: ActivePermissionProfile, -) -> PermissionProfileSelectionParams { - PermissionProfileSelectionParams::new(active.id) +fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String { + active.id } fn turn_permissions_overrides( @@ -1193,13 +1190,13 @@ fn turn_permissions_overrides( cwd: &std::path::Path, ) -> ( Option, - Option, + Option, ) { match permissions_override { TurnPermissionsOverride::Preserve => (None, None), TurnPermissionsOverride::ActiveProfile(active_permission_profile) => ( None, - Some(permissions_selection_from_active_profile( + Some(permission_profile_id_from_active_profile( active_permission_profile, )), ), @@ -1220,7 +1217,7 @@ fn turn_permissions_overrides( fn permissions_selection_from_config( config: &Config, thread_params_mode: ThreadParamsMode, -) -> Option { +) -> Option { if matches!(thread_params_mode, ThreadParamsMode::Remote) { return None; } @@ -1228,7 +1225,7 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) } fn thread_start_params_from_config( @@ -1671,7 +1668,7 @@ mod tests { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(permission_profile_id_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1698,7 +1695,7 @@ mod tests { let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + permission_profile_id_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), @@ -1723,9 +1720,7 @@ mod tests { assert_eq!(sandbox_policy, None); assert_eq!( permissions, - Some(PermissionProfileSelectionParams::new( - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - )) + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) ); } @@ -1763,7 +1758,7 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new("strict"); let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + permission_profile_id_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( TurnPermissionsOverride::ActiveProfile(active_permission_profile), From 17d552fb4d53d602eab87076acff1239a0420708 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 18:13:38 -0700 Subject: [PATCH 087/114] [codex] Remove external websocket session resets (#23384) ## Why Compaction now installs replacement history inside the session, but the turn and compaction callers were still reaching into `ModelClientSession` to reset websocket transport state after that install. That made a transport-level reset part of the compaction API even though websocket incremental request selection already checks whether the next request is a strict extension of the previous one and falls back to a full `response.create` when it is not. ## What changed - Removed the compaction-side calls to `reset_websocket_session` from `compact.rs` and `session/turn.rs`. - Simplified pre-sampling and mid-turn compaction helpers so they return `CodexResult<()>` instead of carrying a reset flag. - Made `ModelClientSession::reset_websocket_session` private to `client.rs`, leaving only the websocket timeout recovery path inside the client as a caller. ## Validation - `cargo test -p codex-core --test all responses_websocket_creates_on_non_prefix` - `cargo test -p codex-core --test all steered_user_input_waits_for_model_continuation_after_mid_turn_compact` - `cargo test -p codex-core --test all pre_sampling_compact_runs_on_switch_to_smaller_context_model` --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/compact.rs | 1 - codex-rs/core/src/session/turn.rs | 77 +++++++++++-------------------- 3 files changed, 29 insertions(+), 51 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f604a63458..cd1fcb6696 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -931,7 +931,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - pub(crate) fn reset_websocket_session(&mut self) { + fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d1c82eb65..b5802268d3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -282,7 +282,6 @@ async fn run_compact_task_inner_impl( }; sess.replace_compacted_history(new_history, reference_context_item, compacted_item) .await; - client_session.reset_websocket_session(); sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 0c5e00927a..95b0f5e312 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -160,25 +160,18 @@ pub(crate) async fn run_turn( // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - let pre_sampling_compact = - match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - Ok(pre_sampling_compact) => pre_sampling_compact, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } - error!("Failed to run pre-sampling compact"); - return None; - } - }; - if pre_sampling_compact.reset_client_session { - client_session.reset_websocket_session(); + if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + error!("Failed to run pre-sampling compact"); + return None; } let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); @@ -519,7 +512,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - let reset_client_session = match run_auto_compact( + match run_auto_compact( &sess, &turn_context, &mut client_session, @@ -529,7 +522,7 @@ pub(crate) async fn run_turn( ) .await { - Ok(reset_client_session) => reset_client_session, + Ok(()) => {} Err(err) => { if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess @@ -545,9 +538,6 @@ pub(crate) async fn run_turn( return None; } }; - if reset_client_session { - client_session.reset_websocket_session(); - } can_drain_pending_input = !model_needs_follow_up; continue; } @@ -767,24 +757,19 @@ async fn track_turn_resolved_config_analytics( }); } -struct PreSamplingCompactResult { - reset_client_session: bool, -} - async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { +) -> CodexResult<()> { let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact( + maybe_run_previous_model_inline_compact( sess, turn_context, client_session, total_usage_tokens_before_compaction, ) .await?; - let mut reset_client_session = pre_sampling_compacted; let total_usage_tokens = sess.get_total_token_usage().await; let auto_compact_limit = turn_context .model_info @@ -792,7 +777,7 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - reset_client_session |= run_auto_compact( + run_auto_compact( sess, turn_context, client_session, @@ -801,27 +786,22 @@ async fn run_pre_sampling_compact( CompactionPhase::PreTurn, ) .await?; - pre_sampling_compacted = true; } - Ok(PreSamplingCompactResult { - reset_client_session: pre_sampling_compacted && reset_client_session, - }) + Ok(()) } /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped -/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction -/// was attempted and failed. +/// Returns `Err(_)` only when compaction was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, total_usage_tokens: i64, -) -> CodexResult { +) -> CodexResult<()> { let Some(previous_turn_settings) = sess.previous_turn_settings().await else { - return Ok(false); + return Ok(()); }; let previous_model_turn_context = Arc::new( turn_context @@ -830,10 +810,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_model_turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let new_auto_compact_limit = turn_context .model_info @@ -843,7 +823,7 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - let _ = run_auto_compact( + run_auto_compact( sess, &previous_model_turn_context, client_session, @@ -852,9 +832,8 @@ async fn maybe_run_previous_model_inline_compact( CompactionPhase::PreTurn, ) .await?; - return Ok(true); } - Ok(false) + Ok(()) } async fn run_auto_compact( @@ -864,7 +843,7 @@ async fn run_auto_compact( initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, -) -> CodexResult { +) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { if turn_context.features.enabled(Feature::RemoteCompactionV2) { run_inline_remote_auto_compact_task_v2( @@ -876,7 +855,7 @@ async fn run_auto_compact( phase, ) .await?; - return Ok(false); + return Ok(()); } run_inline_remote_auto_compact_task( Arc::clone(sess), @@ -896,7 +875,7 @@ async fn run_auto_compact( ) .await?; } - Ok(true) + Ok(()) } pub(super) fn collect_explicit_app_ids_from_skill_items( From 6b54ced108afdc8eba2218672ba0b9790f246782 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Mon, 18 May 2026 18:24:19 -0700 Subject: [PATCH 088/114] cleanup: Remove skill env var dependency prompting (#22721) Deletes the skill env var dependency prompt feature and its runtime path. env_var entries in skill dependency metadata are now silently ignored during skill loading. --- .../core-skills/src/env_var_dependencies.rs | 30 ----- codex-rs/core-skills/src/lib.rs | 3 - codex-rs/core-skills/src/loader_tests.rs | 13 -- codex-rs/core/src/lib.rs | 2 - codex-rs/core/src/session/mod.rs | 10 -- codex-rs/core/src/session/turn.rs | 11 -- codex-rs/core/src/skills.rs | 126 ------------------ codex-rs/core/src/state/session.rs | 13 -- codex-rs/core/src/tools/handlers/shell.rs | 14 +- codex-rs/features/src/lib.rs | 7 +- 10 files changed, 6 insertions(+), 223 deletions(-) delete mode 100644 codex-rs/core-skills/src/env_var_dependencies.rs diff --git a/codex-rs/core-skills/src/env_var_dependencies.rs b/codex-rs/core-skills/src/env_var_dependencies.rs deleted file mode 100644 index 4d8e57164c..0000000000 --- a/codex-rs/core-skills/src/env_var_dependencies.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::SkillMetadata; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SkillDependencyInfo { - pub skill_name: String, - pub name: String, - pub description: Option, -} - -pub fn collect_env_var_dependencies( - mentioned_skills: &[SkillMetadata], -) -> Vec { - let mut dependencies = Vec::new(); - for skill in mentioned_skills { - let Some(skill_dependencies) = &skill.dependencies else { - continue; - }; - for tool in &skill_dependencies.tools { - if tool.r#type != "env_var" || tool.value.is_empty() { - continue; - } - dependencies.push(SkillDependencyInfo { - skill_name: skill.name.clone(), - name: tool.value.clone(), - description: tool.description.clone(), - }); - } - } - dependencies -} diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index eec3a5f054..1577662bc5 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -1,5 +1,4 @@ pub mod config_rules; -mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; @@ -10,8 +9,6 @@ pub mod remote; pub mod render; pub mod system; -pub use env_var_dependencies::SkillDependencyInfo; -pub use env_var_dependencies::collect_env_var_dependencies; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 65a0b5a4f1..a1d03dead2 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -399,11 +399,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { { "dependencies": { "tools": [ - { - "type": "env_var", - "value": "GITHUB_TOKEN", - "description": "GitHub API token with repo scopes" - }, { "type": "mcp", "value": "github", @@ -446,14 +441,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { interface: None, dependencies: Some(SkillDependencies { tools: vec![ - SkillToolDependency { - r#type: "env_var".to_string(), - value: "GITHUB_TOKEN".to_string(), - description: Some("GitHub API token with repo scopes".to_string()), - transport: None, - command: None, - url: None, - }, SkillToolDependency { r#type: "mcp".to_string(), value: "github".to_string(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 325d65745f..c79598ee14 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -90,13 +90,11 @@ pub(crate) use skills::SkillsManager; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; pub(crate) use skills::build_skill_name_counts; -pub(crate) use skills::collect_env_var_dependencies; pub(crate) use skills::collect_explicit_skill_mentions; pub(crate) use skills::default_skill_metadata_budget; pub(crate) use skills::injection; pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; -pub(crate) use skills::resolve_skill_dependencies_for_turn; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; pub mod test_support; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4db6c97e4b..9bac9e3c3a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3019,16 +3019,6 @@ impl Session { state.record_mcp_dependency_prompted(names); } - pub async fn dependency_env(&self) -> HashMap { - let state = self.state.lock().await; - state.dependency_env() - } - - pub async fn set_dependency_env(&self, values: HashMap) { - let mut state = self.state.lock().await; - state.set_dependency_env(values); - } - pub(crate) async fn set_server_reasoning_included(&self, included: bool) { let mut state = self.state.lock().await; state.set_server_reasoning_included(included); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 95b0f5e312..8edf2e4785 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -9,7 +9,6 @@ use crate::build_skill_injections; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; -use crate::collect_env_var_dependencies; use crate::collect_explicit_skill_mentions; use crate::compact::InitialContextInjection; use crate::compact::collect_user_messages; @@ -40,7 +39,6 @@ use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; use crate::parse_turn_item; use crate::plugins::build_plugin_injections; -use crate::resolve_skill_dependencies_for_turn; use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -234,15 +232,6 @@ pub(crate) async fn run_turn( &connector_slug_counts, ) }); - let config = turn_context.config.clone(); - if config - .features - .enabled(Feature::SkillEnvVarDependencyPrompt) - { - let env_var_dependencies = collect_env_var_dependencies(&mentioned_skills); - resolve_skill_dependencies_for_turn(&sess, &turn_context, &env_var_dependencies).await; - } - maybe_prompt_and_install_mcp_dependencies( sess.as_ref(), turn_context.as_ref(), diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 2cceb2be36..d540cd6d43 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -1,8 +1,3 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::env; -use std::sync::Arc; - use crate::config::Config; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -10,14 +5,9 @@ use codex_analytics::InvocationType; use codex_analytics::SkillInvocation; use codex_analytics::build_track_events_context; use codex_protocol::protocol::SkillScope; -use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_protocol::request_user_input::RequestUserInputQuestion; -use codex_protocol::request_user_input::RequestUserInputResponse; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; -use tracing::warn; -pub use codex_core_skills::SkillDependencyInfo; pub use codex_core_skills::SkillError; pub use codex_core_skills::SkillLoadOutcome; pub use codex_core_skills::SkillMetadata; @@ -27,7 +17,6 @@ pub use codex_core_skills::SkillsLoadInput; pub use codex_core_skills::SkillsManager; pub use codex_core_skills::build_available_skills; pub use codex_core_skills::build_skill_name_counts; -pub use codex_core_skills::collect_env_var_dependencies; pub use codex_core_skills::config_rules; pub use codex_core_skills::default_skill_metadata_budget; pub use codex_core_skills::detect_implicit_skill_invocation_for_command; @@ -56,121 +45,6 @@ pub(crate) fn skills_load_input_from_config( ) } -pub(crate) async fn resolve_skill_dependencies_for_turn( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - if dependencies.is_empty() { - return; - } - - let existing_env = sess.dependency_env().await; - let mut loaded_values = HashMap::new(); - let mut missing = Vec::new(); - let mut seen_names = HashSet::new(); - - for dependency in dependencies { - let name = dependency.name.clone(); - if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) { - continue; - } - match env::var(&name) { - Ok(value) => { - loaded_values.insert(name.clone(), value); - } - Err(env::VarError::NotPresent) => { - missing.push(dependency.clone()); - } - Err(err) => { - warn!("failed to read env var {name}: {err}"); - missing.push(dependency.clone()); - } - } - } - - if !loaded_values.is_empty() { - sess.set_dependency_env(loaded_values).await; - } - - if !missing.is_empty() { - request_skill_dependencies(sess, turn_context, &missing).await; - } -} - -async fn request_skill_dependencies( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - let questions = dependencies - .iter() - .map(|dependency| { - let requirement = dependency.description.as_ref().map_or_else( - || { - format!( - "The skill \"{}\" requires \"{}\" to be set.", - dependency.skill_name, dependency.name - ) - }, - |description| { - format!( - "The skill \"{}\" requires \"{}\" to be set ({}).", - dependency.skill_name, dependency.name, description - ) - }, - ); - RequestUserInputQuestion { - id: dependency.name.clone(), - header: "Skill requires environment variable".to_string(), - question: format!( - "{requirement} This is an experimental internal feature. The value is stored in memory for this session only." - ), - is_other: false, - is_secret: true, - options: None, - } - }) - .collect::>(); - if questions.is_empty() { - return; - } - - let response = sess - .request_user_input( - turn_context, - format!("skill-deps-{}", turn_context.sub_id), - RequestUserInputArgs { questions }, - ) - .await - .unwrap_or_else(|| RequestUserInputResponse { - answers: HashMap::new(), - }); - if response.answers.is_empty() { - return; - } - - let mut values = HashMap::new(); - for (name, answer) in response.answers { - let mut user_note = None; - for entry in &answer.answers { - if let Some(note) = entry.strip_prefix("user_note: ") - && !note.trim().is_empty() - { - user_note = Some(note.trim().to_string()); - } - } - if let Some(value) = user_note { - values.insert(name, value); - } - } - if values.is_empty() { - return; - } - - sess.set_dependency_env(values).await; -} - pub(crate) async fn maybe_emit_implicit_skill_invocation( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 3bd4b8a26e..7121a77524 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,7 +3,6 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; -use std::collections::HashMap; use std::collections::HashSet; use crate::context_manager::ContextManager; @@ -22,7 +21,6 @@ pub(crate) struct SessionState { pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, - pub(crate) dependency_env: HashMap, pub(crate) mcp_dependency_prompted: HashSet, /// Settings used by the latest regular user turn, used for turn-to-turn /// model/realtime handling on subsequent regular turns (including full-context @@ -45,7 +43,6 @@ impl SessionState { history, latest_rate_limits: None, server_reasoning_included: false, - dependency_env: HashMap::new(), mcp_dependency_prompted: HashSet::new(), previous_turn_settings: None, startup_prewarm: None, @@ -165,16 +162,6 @@ impl SessionState { self.mcp_dependency_prompted.clone() } - pub(crate) fn set_dependency_env(&mut self, values: HashMap) { - for (key, value) in values { - self.dependency_env.insert(key, value); - } - } - - pub(crate) fn dependency_env(&self) -> HashMap { - self.dependency_env.clone() - } - pub(crate) fn set_session_startup_prewarm( &mut self, startup_prewarm: SessionStartupPrewarmHandle, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 2c69566b0c..09eb29b1e4 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -70,7 +70,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { continue; } + "skill_env_var_dependency_prompt" => { + continue; + } "use_legacy_landlock" => { self.record_legacy_usage_force( "features.use_legacy_landlock", @@ -1037,7 +1040,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::SkillEnvVarDependencyPrompt, key: "skill_env_var_dependency_prompt", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { From daa11820b076b32acd13ee453f92fdf54d457b1c Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 18 May 2026 18:24:39 -0700 Subject: [PATCH 089/114] Remove ToolSearch feature toggle (#23389) ## Summary - mark `ToolSearch` as removed and ignore stale config writes for its legacy key - make search tool exposure depend only on model capability, not a feature toggle - remove app-server enablement support and prune now-obsolete test coverage/setup ## Verification - `cargo test -p codex-features` - `cargo test -p codex-tools` - `cargo test -p codex-core search_tool_requires_model_capability` - `cargo test -p codex-app-server experimental_feature_enablement_set_` ## Notes - This keeps the legacy config key as a no-op for compatibility while removing the ability to toggle the behavior off cleanly. - No developer-facing docs update outside the touched app-server README was needed. --- codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 1 - .../suite/v2/experimental_feature_list.rs | 11 +--- .../core/src/tools/spec_plan_model_tests.rs | 3 - codex-rs/core/src/tools/spec_plan_tests.rs | 34 ++--------- codex-rs/core/tests/suite/code_mode.rs | 4 -- codex-rs/core/tests/suite/search_tool.rs | 57 ------------------- codex-rs/features/src/lib.rs | 9 ++- codex-rs/features/src/tests.rs | 8 +-- codex-rs/tools/src/tool_config.rs | 3 +- 10 files changed, 17 insertions(+), 115 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e2978861de..7a811ab694 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,7 +189,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b076..5054ea248d 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -53,7 +53,6 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", - "tool_search", "tool_suggest", "tool_call_mcp_elicitation", ]; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 279fa60da1..d6101995fa 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -340,7 +340,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -353,7 +352,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -383,13 +381,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("tool_search")), - Some(&json!(true)) - ); assert_eq!( config .additional @@ -483,7 +474,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs index 3390d26e0d..1896a73456 100644 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -793,7 +793,6 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); features.enable(Feature::Apps); features.enable(Feature::Plugins); @@ -832,7 +831,6 @@ async fn search_tool_is_hidden_without_deferred_tools() { let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -863,7 +861,6 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 3442fbb5ff..1b64093ad5 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1653,7 +1653,6 @@ fn search_tool_description_lists_each_mcp_source_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1726,7 +1725,7 @@ fn search_tool_description_lists_each_mcp_source_once() { } #[test] -fn search_tool_requires_model_capability_and_enabled_feature() { +fn search_tool_requires_model_capability() { let model_info = search_capable_model_info(); let deferred_mcp_tools = Some(vec![deferred_mcp_tool( "_create_event", @@ -1759,26 +1758,6 @@ fn search_tool_requires_model_capability_and_enabled_feature() { ); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let mut features_without_tool_search = Features::with_defaults(); - features_without_tool_search.disable(Feature::ToolSearch); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features_without_tool_search, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools.clone(), - &[], - ); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, @@ -1801,8 +1780,7 @@ fn search_tool_requires_model_capability_and_enabled_feature() { #[test] fn no_search_tool_when_namespaces_disabled() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let mut tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1836,8 +1814,7 @@ fn no_search_tool_when_namespaces_disabled() { #[test] fn search_tool_registers_for_deferred_dynamic_tools() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1896,8 +1873,7 @@ fn search_tool_registers_for_deferred_dynamic_tools() { #[test] fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_disabled() { let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); + let features = Features::with_defaults(); let available_models = Vec::new(); let mut tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1946,7 +1922,6 @@ fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_dis fn request_plugin_install_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::ToolSearch); features.enable(Feature::Apps); features.enable(Feature::Plugins); features.disable(Feature::ToolSuggest); @@ -2036,7 +2011,6 @@ fn request_plugin_install_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); - features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index f117631c4a..beae9e4afb 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -400,10 +400,6 @@ if (!tool) { .features .enable(Feature::Apps) .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSearch) - .expect("test config should allow feature update"); config .features .enable(Feature::CodeMode) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 69dc74dce3..ae897f2774 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; -use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; @@ -113,14 +112,6 @@ fn tool_search_output_has_namespace_child( namespace_child_tool(&output, namespace, tool_name).is_some() } -fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) { - configure_search_capable_apps(config, apps_base_url); - config - .features - .disable(Feature::ToolSearch) - .expect("test config should allow feature update"); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); @@ -225,54 +216,6 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_search_disabled_exposes_apps_tools_directly() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount_searchable(&server).await?; - let mock = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-1"), - ]), - ) - .await; - - let mut builder = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| { - configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) - }); - let test = builder.build(&server).await?; - - test.submit_turn_with_approval_and_permission_profile( - "list tools", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - - let body = mock.single_request().body_json(); - let tools = tool_names(&body); - assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); - assert!( - namespace_child_tool( - &body, - SEARCH_CALENDAR_NAMESPACE, - SEARCH_CALENDAR_CREATE_TOOL - ) - .is_some() - ); - assert!( - namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some() - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index fca2473c67..c20fa8683a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -132,7 +132,7 @@ pub enum Feature { EnableMcpApps, /// Use the new path for the host-owned apps MCP server. AppsMcpPathOverride, - /// Enable the tool_search tool for apps. + /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. ToolSearchAlwaysDeferMcpTools, @@ -428,6 +428,9 @@ impl Features { "apply_patch_freeform" => { continue; } + "tool_search" => { + continue; + } "image_detail_original" => { continue; } @@ -946,8 +949,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSearch, key: "tool_search", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::ToolSearchAlwaysDeferMcpTools, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index f4aa3982b9..e2a456f1bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -188,9 +188,10 @@ fn network_proxy_is_experimental_and_disabled_by_default() { } #[test] -fn tool_search_is_stable_and_enabled_by_default() { - assert_eq!(Feature::ToolSearch.stage(), Stage::Stable); - assert_eq!(Feature::ToolSearch.default_enabled(), true); +fn tool_search_is_removed_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::Removed); + assert_eq!(Feature::ToolSearch.default_enabled(), false); + assert_eq!(feature_for_key("tool_search"), Some(Feature::ToolSearch)); } #[test] @@ -603,7 +604,6 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( features.enable(Feature::CodeMode); features.enable(Feature::MultiAgentV2); features.enable(Feature::NetworkProxy); - features.disable(Feature::ToolSearch); let mut features_toml = FeaturesToml { multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 5c1681d164..b6f4ae8dbd 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -182,8 +182,7 @@ impl ToolsConfig { let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_collab_tools = include_multi_agent_v2 || features.enabled(Feature::Collab); let include_agent_jobs = features.enabled(Feature::SpawnCsv); - let include_search_tool = - model_info.supports_search_tool && features.enabled(Feature::ToolSearch); + let include_search_tool = model_info.supports_search_tool; let include_tool_suggest = features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) && features.enabled(Feature::Plugins); From 84d941d07ffd2197eed0311ddd58f21e6b4350f2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 18:48:35 -0700 Subject: [PATCH 090/114] [1 of 7] Add thread settings to UserInput (#23080) **Stack position:** [1 of 7] ## Summary The first three PRs in this stack are a cleanup pass before the actual thread settings API work. Today, core has several overlapping "user input" ops: `UserInput`, `UserInputWithTurnContext`, and `UserTurn`. They differ mostly in how much next-turn state they carry, which makes the later queued thread settings update harder to reason about and review. This PR starts that cleanup by adding the shared `ThreadSettingsOverrides` payload and allowing `Op::UserInput` to carry it. Existing variants remain in place here, so this layer is mostly a behavior-preserving API shape change plus mechanical constructor updates. ## End State After PR3 By the end of PR3, `Op::UserInput` is the only "user input" core op. It can carry optional thread settings overrides for callers that need to update stored defaults with a turn, while callers without updates use empty settings. `Op::UserInputWithTurnContext` and `Op::UserTurn` are deleted. ## End State After PR5 By the end of PR5, core will have only two ops for this area: - `Op::UserInput` for user-input-bearing submissions. - `Op::ThreadSettings` for settings-only updates. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) (this PR) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 2 +- .../src/request_processors/turn_processor.rs | 9 +- .../app-server/tests/suite/v2/turn_start.rs | 6 +- codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_thread.rs | 12 +- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/session/handlers.rs | 103 +++++++++++++----- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/tests.rs | 6 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/client.rs | 32 ++++++ .../core/tests/suite/client_websockets.rs | 2 + .../tests/suite/collaboration_instructions.rs | 14 +++ codex-rs/core/tests/suite/compact.rs | 31 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 53 +++++++++ .../core/tests/suite/compact_remote_parity.rs | 1 + .../core/tests/suite/compact_resume_fork.rs | 3 +- codex-rs/core/tests/suite/fork_thread.rs | 2 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/items.rs | 9 ++ .../core/tests/suite/model_visible_layout.rs | 3 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 12 +- codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 3 + .../core/tests/suite/request_compression.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/search_tool.rs | 4 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/protocol.rs | 94 ++++++++++++++-- codex-rs/thread-manager-sample/src/main.rs | 1 + 43 files changed, 429 insertions(+), 52 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7a811ab694..9073282b4e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -832,7 +832,7 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi ### Example: Steer an active turn Use `turn/steer` to append additional user input to the currently active regular turn. This does -not emit `turn/started` and does not accept turn context overrides. +not emit `turn/started` and does not accept thread settings overrides. ```json { "method": "turn/steer", "id": 32, "params": { diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index ba28840f50..305e864ba0 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -259,7 +259,7 @@ use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; -use codex_core::CodexThreadTurnContextOverrides; +use codex_core::CodexThreadSettingsOverrides; use codex_core::ExternalGoalPreviousStatus; use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; 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 55a4e3a838..3de4ebbcc3 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -457,7 +457,7 @@ impl TurnRequestProcessor { warning.contains("Configured value for `permission_profile` is disallowed") }) { return Err(invalid_request(format!( - "invalid turn context override: {warning}" + "invalid thread settings override: {warning}" ))); } ( @@ -479,7 +479,7 @@ impl TurnRequestProcessor { // still queued together with the input below to preserve submission order. if has_any_overrides { thread - .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + .validate_thread_settings_overrides(CodexThreadSettingsOverrides { cwd: cwd.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, @@ -497,7 +497,9 @@ impl TurnRequestProcessor { personality, }) .await - .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; + .map_err(|err| { + invalid_request(format!("invalid thread settings override: {err}")) + })?; } // Start the turn by submitting the user input. Return its submission id as turn_id. @@ -529,6 +531,7 @@ impl TurnRequestProcessor { environments: environment_selections, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, + thread_settings: Default::default(), } }; let turn_id = self diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index ae74b3432f..62f0725ca1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -900,7 +900,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() .await??; assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); - assert!(err.error.message.contains("invalid turn context override")); + assert!( + err.error + .message + .contains("invalid thread settings override") + ); assert!( err.error.message.contains("allowed set [ReadOnly]"), "unexpected error message: {}", diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e5883f60da..b9ce39887a 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -442,6 +442,7 @@ async fn send_input_submits_user_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness @@ -595,6 +596,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness @@ -743,6 +745,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, ); let captured = harness diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 493771e28c..4ed6b2368c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot( items: input, final_output_json_schema, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8f5023300c..aff76b8c02 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -80,9 +80,9 @@ impl ThreadConfigSnapshot { } } -/// Turn context overrides that app-server validates before starting a turn. +/// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] -pub struct CodexThreadTurnContextOverrides { +pub struct CodexThreadSettingsOverrides { pub cwd: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, @@ -257,12 +257,12 @@ impl CodexThread { .await } - /// Validate persistent turn context overrides without committing them. - pub async fn validate_turn_context_overrides( + /// Validate persistent thread settings overrides without committing them. + pub async fn validate_thread_settings_overrides( &self, - overrides: CodexThreadTurnContextOverrides, + overrides: CodexThreadSettingsOverrides, ) -> ConstraintResult<()> { - let CodexThreadTurnContextOverrides { + let CodexThreadSettingsOverrides { cwd, workspace_roots, profile_workspace_roots, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c79598ee14..aca6049698 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,7 +20,7 @@ mod compact_remote; mod compact_remote_v2; mod config_lock; pub use codex_thread::CodexThread; -pub use codex_thread::CodexThreadTurnContextOverrides; +pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; pub use session::turn_context::TurnContext; mod agent; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index eb27ae78be..e684ae6f3b 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -42,6 +42,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -184,20 +185,9 @@ pub(super) async fn user_input_or_turn_inner( personality, environments, } => { - let collaboration_mode = if let Some(collab_mode) = collaboration_mode { - Some(collab_mode) - } else { - let state = sess.state.lock().await; - Some( - state - .session_configuration - .collaboration_mode - .with_updates(model, effort, /*developer_instructions*/ None), - ) - }; - ( - items, - SessionSettingsUpdate { + let mut updates = thread_settings_update( + sess, + ThreadSettingsOverrides { cwd, workspace_roots, profile_workspace_roots, @@ -207,32 +197,35 @@ pub(super) async fn user_input_or_turn_inner( permission_profile, active_permission_profile, windows_sandbox_level, - collaboration_mode, - reasoning_summary: summary, + model, + effort, + summary, service_tier, - final_output_json_schema: Some(final_output_json_schema), - environments, + collaboration_mode, personality, - app_server_client_name: None, - app_server_client_version: None, }, - responsesapi_client_metadata, ) + .await; + updates.final_output_json_schema = Some(final_output_json_schema); + updates.environments = environments; + (items, updates, responsesapi_client_metadata) } Op::UserInput { items, environments, final_output_json_schema, responsesapi_client_metadata, - } => ( - items, - SessionSettingsUpdate { - final_output_json_schema: Some(final_output_json_schema), - environments, - ..Default::default() - }, - responsesapi_client_metadata, - ), + thread_settings, + } => { + let mut updates = if thread_settings == ThreadSettingsOverrides::default() { + SessionSettingsUpdate::default() + } else { + thread_settings_update(sess, thread_settings).await + }; + updates.final_output_json_schema = Some(final_output_json_schema); + updates.environments = environments; + (items, updates, responsesapi_client_metadata) + } _ => unreachable!(), }; @@ -289,6 +282,56 @@ pub(super) async fn user_input_or_turn_inner( } } +async fn thread_settings_update( + sess: &Session, + thread_settings: ThreadSettingsOverrides, +) -> SessionSettingsUpdate { + let ThreadSettingsOverrides { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = thread_settings; + let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { + collaboration_mode + } else { + let state = sess.state.lock().await; + // Model and reasoning effort live in CollaborationMode settings today, so + // partial thread-settings updates refresh those fields on the active mode. + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, /*developer_instructions*/ None) + }; + SessionSettingsUpdate { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + } +} + async fn mirror_user_text_to_realtime(sess: &Arc, items: &[UserInput]) { let text = UserMessageItem::new(items).message(); if text.is_empty() { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9bac9e3c3a..7c3b4803dd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1094,6 +1094,7 @@ impl Session { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, /*mirror_user_text_to_realtime*/ None, ) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4486d287c5..46e93b16f4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2221,6 +2221,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2284,6 +2285,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -5220,6 +5222,7 @@ fn op_kind_distinguishes_turn_ops() { items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), } .kind(), "user_input" @@ -8369,6 +8372,7 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -8473,6 +8477,7 @@ async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -8957,6 +8962,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; 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 a83167ffc5..2846cacb70 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2470,6 +2470,7 @@ async fn send_input_accepts_structured_items() { ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c81a1c2f68..c41502a9dc 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -53,6 +53,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -109,6 +110,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -129,6 +131,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -211,6 +214,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -231,6 +235,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 42bacb9bdc..818e0eba7b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -392,6 +392,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -757,6 +758,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -967,6 +969,7 @@ async fn includes_base_instructions_override_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1023,6 +1026,7 @@ async fn chatgpt_auth_sends_correct_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1146,6 +1150,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1184,6 +1189,7 @@ async fn includes_user_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1271,6 +1277,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1333,6 +1340,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1391,6 +1399,7 @@ async fn omits_apps_guidance_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1432,6 +1441,7 @@ async fn omits_environment_context_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1488,6 +1498,7 @@ async fn skills_append_to_developer_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1570,6 +1581,7 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1630,6 +1642,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1671,6 +1684,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1713,6 +1727,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1826,6 +1841,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1945,6 +1961,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2003,6 +2020,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2041,6 +2059,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2088,6 +2107,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2134,6 +2154,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2185,6 +2206,7 @@ async fn includes_developer_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2480,6 +2502,7 @@ async fn token_count_includes_rate_limits_snapshot() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2618,6 +2641,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2694,6 +2718,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2708,6 +2733,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2792,6 +2818,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2902,6 +2929,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2990,6 +3018,7 @@ async fn env_var_overrides_loaded_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3046,6 +3075,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3061,6 +3091,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3076,6 +3107,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index a14ee6518f..feee9d4227 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1208,6 +1208,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1296,6 +1297,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 5ce115cdaf..2b0c20ab26 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -86,6 +86,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -148,6 +149,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -305,6 +307,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -434,6 +437,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -523,6 +528,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -553,6 +559,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -614,6 +621,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -647,6 +655,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -709,6 +718,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -742,6 +752,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -808,6 +819,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -823,6 +835,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -882,6 +895,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index be3423a85e..d1384ff800 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -386,6 +386,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -410,6 +411,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -586,6 +588,7 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -658,6 +661,7 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -727,6 +731,7 @@ async fn manual_compact_uses_custom_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user turn"); @@ -873,6 +878,7 @@ async fn manual_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1038,6 +1044,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -1509,6 +1516,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1524,6 +1532,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1539,6 +1548,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1709,6 +1719,7 @@ async fn auto_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1789,6 +1800,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1803,6 +1815,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1817,6 +1830,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2250,6 +2264,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2264,6 +2279,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2278,6 +2294,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2366,6 +2383,7 @@ async fn manual_compact_retries_after_context_window_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2469,6 +2487,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -2563,6 +2582,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2580,6 +2600,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2597,6 +2618,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2760,6 +2782,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -2864,6 +2887,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3063,6 +3087,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3181,6 +3206,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -3242,6 +3268,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -3281,6 +3308,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user input"); @@ -3469,6 +3497,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit first user"); @@ -3483,6 +3512,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit second user"); @@ -3555,6 +3585,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 15197042fa..9db94016e6 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -326,6 +326,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -342,6 +343,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -516,6 +518,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -535,6 +538,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -548,6 +552,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -567,6 +572,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -580,6 +586,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -747,6 +754,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -763,6 +771,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -856,6 +865,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -872,6 +882,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -962,6 +973,7 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_turn_complete(&codex).await; @@ -1033,6 +1045,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1113,6 +1126,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1126,6 +1140,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1243,6 +1258,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1256,6 +1272,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1275,6 +1292,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1374,6 +1392,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1387,6 +1406,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1480,6 +1500,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&baseline_codex, |event| { @@ -1496,6 +1517,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&baseline_codex, |event| { @@ -1586,6 +1608,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&override_codex, |event| { @@ -1602,6 +1625,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&override_codex, |event| { @@ -1672,6 +1696,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1752,6 +1777,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1835,6 +1861,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1977,6 +2004,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1994,6 +2022,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2018,6 +2047,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2113,6 +2143,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2129,6 +2160,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2200,6 +2232,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2213,6 +2246,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2279,6 +2313,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2339,6 +2374,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2354,6 +2390,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2428,6 +2465,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2444,6 +2482,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2565,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2541,6 +2581,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2631,6 +2672,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2660,6 +2702,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2756,6 +2799,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2842,6 +2886,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2871,6 +2916,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2989,6 +3035,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3002,6 +3049,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -3086,6 +3134,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3163,6 +3212,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3248,6 +3298,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3264,6 +3315,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3345,6 +3397,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 32c13b0b27..ccbd427b1f 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -606,6 +606,7 @@ async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec Resu } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -/// Scenario: rolling back a turn that introduced persistent pre-turn context +/// Scenario: rolling back a turn that introduced persistent thread settings /// diffs should trim those context updates so the next request includes them /// only once. async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { @@ -790,6 +790,7 @@ async fn user_turn(conversation: &Arc, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 37456dce63..68a167f790 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -57,6 +57,7 @@ async fn fork_thread_twice_drops_to_first_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -180,6 +181,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index ef3918e189..4f376573b3 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1440,6 +1440,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1458,6 +1459,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 65087a1fe7..0015a418ca 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -121,6 +121,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -179,6 +180,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -239,6 +241,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -300,6 +303,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -379,6 +383,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -465,6 +470,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -520,6 +526,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1103,6 +1110,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1157,6 +1165,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 7ae148788e..d9c636b35f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -344,6 +344,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -451,6 +452,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -499,6 +501,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 03692ac547..9d2df4e60e 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -122,6 +122,7 @@ async fn responses_api_emits_api_request_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -167,6 +168,7 @@ async fn process_sse_emits_tracing_for_output_item() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -212,6 +214,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -258,6 +261,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -324,6 +328,7 @@ async fn process_sse_failed_event_records_response_error_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -388,6 +393,7 @@ async fn process_sse_failed_event_logs_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -439,6 +445,7 @@ async fn process_sse_failed_event_logs_missing_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -499,6 +506,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -553,6 +561,7 @@ async fn process_sse_emits_completed_telemetry() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -631,6 +640,7 @@ async fn turn_and_completed_response_spans_record_token_usage() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -718,6 +728,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -811,6 +822,7 @@ async fn record_responses_sets_span_fields_for_response_events() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -899,6 +911,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -974,6 +987,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1050,6 +1064,7 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1158,6 +1173,7 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1212,6 +1228,7 @@ async fn handle_shell_command_user_approved_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1281,6 +1298,7 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1350,6 +1368,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1419,6 +1438,7 @@ async fn handle_shell_command_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1488,6 +1508,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -1558,6 +1579,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 62851c515d..d511c8f0b6 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -103,6 +103,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -285,6 +286,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -303,6 +305,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index c4f1bef968..33d494ae45 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -58,6 +58,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -97,6 +98,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -127,6 +129,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -172,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -185,6 +189,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -230,6 +235,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -260,6 +266,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -318,6 +325,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -350,6 +358,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -365,6 +374,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -424,6 +434,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -456,6 +467,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -477,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -512,6 +525,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -572,6 +586,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 7b10db0259..45fdf3df07 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -227,6 +227,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -305,6 +306,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -386,6 +388,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index f887131eef..2023778a11 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -153,6 +153,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -166,6 +167,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +251,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -262,6 +265,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -326,6 +330,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +344,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -421,6 +427,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -462,6 +469,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -485,7 +493,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - // After overriding the turn context, emit one updated permissions message. + // After overriding thread settings, emit one updated permissions message. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, @@ -547,6 +555,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -700,6 +709,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 4c0677e69a..413855f641 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -48,6 +48,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 31d30d824e..232d2380b0 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2168,6 +2168,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -2302,6 +2303,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -3497,6 +3499,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fddb18f15b..2d66389d53 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -47,6 +47,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -96,6 +97,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 3211d336e5..db64289354 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -93,6 +93,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -178,6 +179,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -267,6 +269,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -309,6 +312,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { @@ -326,6 +330,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { @@ -398,6 +403,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -444,6 +450,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 3611df6a6a..e128a189b5 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -684,6 +684,7 @@ async fn review_history_surfaces_in_parent_session() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index ae897f2774..51f100d3c5 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -437,6 +437,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -747,6 +748,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1054,6 +1056,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -1373,6 +1376,7 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; 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 af86141261..5709cdd12b 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 @@ -101,6 +101,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); @@ -122,6 +123,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 30574718f2..40763960f4 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -83,6 +83,7 @@ async fn retries_on_early_close() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 5fe08789f4..343afd8d53 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -64,6 +64,7 @@ mv "${tmp_path}" "${payload_path}""#, }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index d0e207d963..76fcacf7a9 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -112,6 +112,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9e3ac1bae9..1898bebeeb 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -116,6 +116,7 @@ pub async fn run_codex_tool_session( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }, trace: None, }; @@ -165,6 +166,7 @@ pub async fn run_codex_tool_session_reply( }], final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await { diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 18b1bd6db1..b1ffb2d215 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -261,6 +261,7 @@ impl MemoryStartupContext { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ec780d60c3..cd9078a85c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -396,6 +396,80 @@ pub struct ConversationTextParams { pub text: String, } +/// Persistent thread-settings overrides that can be applied before user input. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct ThreadSettingsOverrides { + /// Updated `cwd` for sandbox/tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Updated runtime workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, + + /// Updated profile-defined workspace roots for status summaries and + /// per-turn config reconstruction. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_workspace_roots: Option>, + + /// Updated command approval policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + + /// Updated approval reviewer for future approval prompts. + #[serde(skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + + /// Updated sandbox policy for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_policy: Option, + + /// Updated permissions profile for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + + /// Named or built-in profile that produced `permission_profile`, if the + /// update selected a profile rather than supplying raw permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_permission_profile: Option, + + /// Updated Windows sandbox mode for tool execution. + #[serde(skip_serializing_if = "Option::is_none")] + pub windows_sandbox_level: Option, + + /// Updated model slug. When set, the model info is derived automatically. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Updated reasoning effort (honored only for reasoning-capable models). + /// + /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the + /// effort, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option>, + + /// Updated reasoning summary preference (honored only for reasoning-capable models). + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Updated service tier preference for future turns. + /// + /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the + /// preference, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option>, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + + /// Updated personality preference. + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -425,10 +499,7 @@ pub enum Op { /// Request the list of voices supported by realtime conversation streams. RealtimeConversationListVoices, - /// Legacy user input. - /// - /// Prefer [`Op::UserTurn`] so the caller provides full turn context - /// (cwd/approval/sandbox/model/etc.) for each turn. + /// User input, optionally with thread-settings overrides applied first. UserInput { /// User input items, see `InputItem` items: Vec, @@ -441,9 +512,13 @@ pub enum Op { /// Optional turn-scoped Responses API `client_metadata`. #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, + + /// Persistent thread-settings overrides to apply before the input. + #[serde(default, flatten)] + thread_settings: ThreadSettingsOverrides, }, - /// Similar to [`Op::UserInput`], but first applies persistent turn-context + /// Similar to [`Op::UserInput`], but first applies persistent thread-settings /// overrides in the same queued operation. This preserves submission order /// and prevents the input from starting if the overrides are rejected. UserInputWithTurnContext { @@ -606,11 +681,11 @@ pub enum Op { communication: InterAgentCommunication, }, - /// Override parts of the persistent turn context for subsequent turns. + /// Override parts of the persistent thread settings for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. /// This does not enqueue any input – it only updates defaults used for - /// turns that rely on persistent session-level context (for example, + /// turns that rely on persistent session-level settings (for example, /// [`Op::UserInput`]). OverrideTurnContext { /// Updated `cwd` for sandbox/tool calls. @@ -790,6 +865,7 @@ impl From> for Op { items: value, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: ThreadSettingsOverrides::default(), } } } @@ -5028,6 +5104,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let json_op = serde_json::to_value(op)?; @@ -5047,6 +5124,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), } ); @@ -5068,6 +5146,7 @@ mod tests { items: Vec::new(), final_output_json_schema: Some(schema.clone()), responsesapi_client_metadata: None, + thread_settings: Default::default(), }; let json_op = serde_json::to_value(op)?; @@ -5093,6 +5172,7 @@ mod tests { "fiber_run_id".to_string(), "fiber-123".to_string(), )])), + thread_settings: Default::default(), }; let json_op = serde_json::to_value(&op)?; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 8a168bad13..8593b8a477 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -293,6 +293,7 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await .context("submit user input")?; From e811234484d8a6b07c4c1018de0a91661ef01841 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 19:41:33 -0700 Subject: [PATCH 091/114] [2 of 7] Remove UserInputWithTurnContext (#23081) **Stack position:** [2 of 7] ## Summary This PR removes the overlapping `Op::UserInputWithTurnContext` variant now that `Op::UserInput` can carry thread settings overrides directly. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) (this PR) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- .../src/request_processors/turn_processor.rs | 54 +++++------ codex-rs/core/src/session/handlers.rs | 50 +---------- codex-rs/core/src/session/tests.rs | 25 ------ codex-rs/protocol/src/protocol.rs | 90 ------------------- 4 files changed, 25 insertions(+), 194 deletions(-) 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 3de4ebbcc3..d383f64d4e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -502,37 +502,31 @@ impl TurnRequestProcessor { })?; } + let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides { + cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + }; + // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = if has_any_overrides { - Op::UserInputWithTurnContext { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - cwd, - workspace_roots: runtime_workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } - } else { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - thread_settings: Default::default(), - } + 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, + thread_settings, }; let turn_id = self .submit_core_op(&request_id, thread.as_ref(), turn_op) diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index e684ae6f3b..f8a4fa2920 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -164,52 +164,6 @@ pub(super) async fn user_input_or_turn_inner( None, ) } - Op::UserInputWithTurnContext { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - final_output_json_schema, - items, - responsesapi_client_metadata, - collaboration_mode, - personality, - environments, - } => { - let mut updates = thread_settings_update( - sess, - ThreadSettingsOverrides { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }, - ) - .await; - updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; - (items, updates, responsesapi_client_metadata) - } Op::UserInput { items, environments, @@ -872,9 +826,7 @@ pub(super) async fn submission_loop( .await; false } - Op::UserInput { .. } - | Op::UserInputWithTurnContext { .. } - | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserTurn { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 46e93b16f4..a6d82e211d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5227,31 +5227,6 @@ fn op_kind_distinguishes_turn_ops() { .kind(), "user_input" ); - assert_eq!( - Op::UserInputWithTurnContext { - environments: None, - items: vec![], - final_output_json_schema: None, - responsesapi_client_metadata: None, - cwd: None, - workspace_roots: None, - profile_workspace_roots: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - active_permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - } - .kind(), - "user_input_with_turn_context" - ); } #[tokio::test] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cd9078a85c..77d862086c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,95 +518,6 @@ pub enum Op { thread_settings: ThreadSettingsOverrides, }, - /// Similar to [`Op::UserInput`], but first applies persistent thread-settings - /// overrides in the same queued operation. This preserves submission order - /// and prevents the input from starting if the overrides are rejected. - UserInputWithTurnContext { - /// User input items, see `InputItem` - items: Vec, - /// Optional turn-scoped environment selections. - #[serde(default, skip_serializing_if = "Option::is_none")] - environments: Option>, - /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[serde(skip_serializing_if = "Option::is_none")] - final_output_json_schema: Option, - /// Optional turn-scoped Responses API `client_metadata`. - #[serde(default, skip_serializing_if = "Option::is_none")] - responsesapi_client_metadata: Option>, - - /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - cwd: Option, - - /// Updated runtime workspace roots used to materialize symbolic - /// `:workspace_roots` filesystem permissions. - #[serde(skip_serializing_if = "Option::is_none")] - workspace_roots: Option>, - - /// Updated profile-defined workspace roots for status summaries and - /// per-turn config reconstruction. - #[serde(skip_serializing_if = "Option::is_none")] - profile_workspace_roots: Option>, - - /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] - approval_policy: Option, - - /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] - approvals_reviewer: Option, - - /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - sandbox_policy: Option, - - /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - permission_profile: Option, - - /// Named or built-in profile that produced `permission_profile`, if - /// the update selected a profile rather than supplying raw - /// permissions. - #[serde(skip_serializing_if = "Option::is_none")] - active_permission_profile: Option, - - /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] - windows_sandbox_level: Option, - - /// Updated model slug. When set, the model info is derived - /// automatically. - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - - /// Updated reasoning effort (honored only for reasoning-capable models). - /// - /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear - /// the effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - effort: Option>, - - /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - - /// Updated service tier preference for future turns. - /// - /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the - /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, - - /// EXPERIMENTAL - set a pre-set collaboration mode. - /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] - collaboration_mode: Option, - - /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] - personality: Option, - }, - /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -932,7 +843,6 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", - Self::UserInputWithTurnContext { .. } => "user_input_with_turn_context", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", From 1a25d8b6e561d90c883b7792b8d2a37d41b7605a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 19:56:00 -0700 Subject: [PATCH 092/114] [3 of 7] Remove UserTurn (#23075) **Stack position:** [3 of 7] ## Summary This PR finishes the input-op consolidation by moving the remaining `Op::UserTurn` callers onto `Op::UserInput` and deleting `Op::UserTurn`. This touches a lot of files, but it is a low-risk mechanical migration. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) (this PR) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- codex-rs/core/src/guardian/review_session.rs | 35 +-- codex-rs/core/src/session/handlers.rs | 55 +---- codex-rs/core/src/session/tests.rs | 35 +-- codex-rs/core/tests/common/test_codex.rs | 42 ++-- codex-rs/core/tests/suite/apply_patch_cli.rs | 31 +-- codex-rs/core/tests/suite/approvals.rs | 63 ++--- codex-rs/core/tests/suite/client.rs | 70 +++--- codex-rs/core/tests/suite/code_mode.rs | 31 +-- .../tests/suite/collaboration_instructions.rs | 96 ++++---- codex-rs/core/tests/suite/compact.rs | 31 +-- codex-rs/core/tests/suite/exec_policy.rs | 64 ++--- codex-rs/core/tests/suite/image_rollout.rs | 62 ++--- codex-rs/core/tests/suite/items.rs | 26 +-- codex-rs/core/tests/suite/json_result.rs | 31 +-- .../core/tests/suite/mcp_turn_metadata.rs | 34 +-- codex-rs/core/tests/suite/model_switching.rs | 31 +-- .../core/tests/suite/model_visible_layout.rs | 157 +++++++------ codex-rs/core/tests/suite/models_cache_ttl.rs | 31 +-- .../core/tests/suite/models_etag_responses.rs | 31 +-- codex-rs/core/tests/suite/pending_input.rs | 31 +-- codex-rs/core/tests/suite/personality.rs | 32 +-- codex-rs/core/tests/suite/prompt_caching.rs | 153 ++++++------ codex-rs/core/tests/suite/remote_env.rs | 31 +-- codex-rs/core/tests/suite/remote_models.rs | 157 ++++--------- .../core/tests/suite/request_permissions.rs | 32 +-- .../tests/suite/request_permissions_tool.rs | 32 +-- .../core/tests/suite/request_user_input.rs | 101 ++++---- .../suite/responses_api_proxy_headers.rs | 31 +-- codex-rs/core/tests/suite/rmcp_client.rs | 31 +-- .../tests/suite/safety_check_downgrade.rs | 31 +-- codex-rs/core/tests/suite/shell_snapshot.rs | 124 +++++----- codex-rs/core/tests/suite/skill_approval.rs | 31 +-- codex-rs/core/tests/suite/skills.rs | 31 +-- codex-rs/core/tests/suite/sqlite_state.rs | 31 +-- codex-rs/core/tests/suite/tool_harness.rs | 155 +++++++------ codex-rs/core/tests/suite/tool_parallelism.rs | 62 ++--- codex-rs/core/tests/suite/truncation.rs | 31 +-- codex-rs/core/tests/suite/unified_exec.rs | 218 ++++++++++-------- codex-rs/core/tests/suite/user_shell_cmd.rs | 31 +-- codex-rs/core/tests/suite/view_image.rs | 31 +-- .../core/tests/suite/websocket_fallback.rs | 31 +-- codex-rs/protocol/src/protocol.rs | 69 ------ 42 files changed, 1289 insertions(+), 1174 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index afb5882a69..389373c8de 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -706,22 +706,29 @@ async fn run_review_on_session( let submit_result = run_before_review_deadline( deadline, params.external_cancel.as_ref(), - Box::pin(review_session.codex.submit(Op::UserTurn { - environments: None, + Box::pin(review_session.codex.submit(Op::UserInput { items: prompt_items.items, - #[allow(deprecated)] - cwd: params.parent_turn.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: legacy_sandbox_policy, - permission_profile: Some(guardian_permission_profile), - model: params.model.clone(), - effort: params.reasoning_effort, - summary: Some(params.reasoning_summary), - service_tier: None, + environments: None, final_output_json_schema: Some(params.schema.clone()), - collaboration_mode: None, - personality: params.personality, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + #[allow(deprecated)] + cwd: Some(params.parent_turn.cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(legacy_sandbox_policy), + permission_profile: Some(guardian_permission_profile), + summary: Some(params.reasoning_summary), + personality: params.personality, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: params.model.clone(), + reasoning_effort: params.reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, })), ) .await; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index f8a4fa2920..e01ee28f99 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -49,9 +49,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Settings; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::items::UserMessageItem; use codex_protocol::mcp::RequestId as ProtocolRequestId; @@ -114,56 +111,6 @@ pub(super) async fn user_input_or_turn_inner( mirror_user_text_to_realtime: Option<()>, ) { let (items, updates, responsesapi_client_metadata) = match op { - Op::UserTurn { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - model, - effort, - summary, - service_tier, - final_output_json_schema, - items, - collaboration_mode, - personality, - environments, - } => { - let collaboration_mode = collaboration_mode.or_else(|| { - Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: model.clone(), - reasoning_effort: effort, - developer_instructions: None, - }, - }) - }); - ( - items, - SessionSettingsUpdate { - cwd: Some(cwd), - approval_policy: Some(approval_policy), - approvals_reviewer, - sandbox_policy: Some(sandbox_policy), - workspace_roots: None, - profile_workspace_roots: None, - permission_profile, - active_permission_profile: None, - windows_sandbox_level: None, - collaboration_mode, - reasoning_summary: summary, - service_tier, - final_output_json_schema: Some(final_output_json_schema), - environments, - personality, - app_server_client_name: None, - app_server_client_version: None, - }, - None, - ) - } Op::UserInput { items, environments, @@ -826,7 +773,7 @@ pub(super) async fn submission_loop( .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a6d82e211d..4f090a8a3a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5197,7 +5197,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { } #[test] -fn op_kind_distinguishes_turn_ops() { +fn op_kind_for_input_and_context_ops() { assert_eq!( Op::OverrideTurnContext { cwd: None, @@ -5237,24 +5237,31 @@ async fn user_turn_updates_approvals_reviewer() { handlers::user_input_or_turn( &session, "sub-1".to_string(), - Op::UserTurn { - environments: None, + Op::UserInput { items: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), }], - cwd: config.cwd.to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), - sandbox_policy: config.legacy_sandbox_policy(), - permission_profile: None, - model: turn_context.model_info.slug.clone(), - effort: config.model_reasoning_effort, - summary: config.model_reasoning_summary, - service_tier: None, + environments: None, final_output_json_schema: None, - collaboration_mode: None, - personality: config.personality, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(config.cwd.to_path_buf()), + approval_policy: Some(config.permissions.approval_policy.value()), + approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), + sandbox_policy: Some(config.legacy_sandbox_policy()), + summary: config.model_reasoning_summary, + personality: config.personality, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: turn_context.model_info.slug.clone(), + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }, ) .await; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 2007104868..a1ba7b8fb2 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -190,8 +190,14 @@ pub enum ApplyPatchModelOutput { ShellCommandViaHeredoc, } -/// Returns the permission fields required by `Op::UserTurn` for tests that -/// construct the op directly. +/// A collection of different ways the model can output an apply_patch call +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShellModelOutput { + ShellCommand, + // UnifiedExec has its own set of tests +} + +/// Returns the permission fields required by test thread-settings overrides. pub fn turn_permission_fields( permission_profile: PermissionProfile, cwd: &Path, @@ -754,24 +760,30 @@ impl TestCodex { turn_permission_fields(permission_profile, self.config.cwd.as_path()); let session_model = self.session_configured.model.clone(); self.codex - .submit(Op::UserTurn { - environments, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments, final_output_json_schema: None, - cwd: self.config.cwd.to_path_buf(), - approval_policy, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(self.config.cwd.to_path_buf()), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + service_tier, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 05379517e5..1e325e1bb5 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -83,24 +83,29 @@ async fn submit_without_wait_with_turn_permissions( let test = harness.test(); let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: harness.cwd().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(harness.cwd().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 5045755e32..f08262eb6f 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -644,24 +644,29 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy, - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy, - permission_profile: None, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2578,24 +2583,30 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run allowed touch under zsh fork".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy, - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 818e0eba7b..f6731a0ea4 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1758,12 +1758,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), ) .await; - let TestCodex { - codex, - config, - session_configured, - .. - } = test_codex().with_model("gpt-5.4").build(&server).await?; + let TestCodex { codex, config, .. } = test_codex().with_model("gpt-5.4").build(&server).await?; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -1775,28 +1770,26 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re }; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - permission_profile: None, - model: session_configured.model.clone(), - effort: Some(ReasoningEffort::Low), - summary: Some( - config - .model_reasoning_summary - .unwrap_or(ReasoningSummary::Auto), - ), - service_tier: None, - collaboration_mode: Some(collaboration_mode), + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(config.cwd.to_path_buf()), + approval_policy: Some(config.permissions.approval_policy.value()), + sandbox_policy: Some(config.legacy_sandbox_policy()), + summary: Some( + config + .model_reasoning_summary + .unwrap_or(ReasoningSummary::Auto), + ), + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; @@ -1898,24 +1891,29 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() .await?; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - permission_profile: None, - model: session_configured.model, - effort: None, - summary: Some(ReasoningSummary::Concise), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(config.cwd.to_path_buf()), + approval_policy: Some(config.permissions.approval_policy.value()), + sandbox_policy: Some(config.legacy_sandbox_policy()), + summary: Some(ReasoningSummary::Concise), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_configured.model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index beae9e4afb..19373b52e4 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2584,24 +2584,29 @@ text( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 2b0c20ab26..bd01dc4e7d 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -178,28 +178,26 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.to_path_buf(), - approval_policy: test.config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: test.config.legacy_sandbox_policy(), - permission_profile: None, - model: test.session_configured.model.clone(), - effort: None, - summary: Some( - test.config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), - ), - service_tier: None, - collaboration_mode: Some(collaboration_mode), + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(test.config.permissions.approval_policy.value()), + sandbox_policy: Some(test.config.legacy_sandbox_policy()), + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -230,28 +228,26 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { let collaboration_mode = collab_mode_with_instructions(Some("turn instructions")); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.to_path_buf(), - approval_policy: test.config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: test.config.legacy_sandbox_policy(), - permission_profile: None, - model: test.session_configured.model.clone(), - effort: None, - summary: Some( - test.config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), - ), - service_tier: None, - collaboration_mode: Some(collaboration_mode), + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(test.config.permissions.approval_policy.value()), + sandbox_policy: Some(test.config.legacy_sandbox_policy()), + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -355,28 +351,26 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu .await?; test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.to_path_buf(), - approval_policy: test.config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: test.config.legacy_sandbox_policy(), - permission_profile: None, - model: test.session_configured.model.clone(), - effort: None, - summary: Some( - test.config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), - ), - service_tier: None, - collaboration_mode: Some(turn_mode), + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(test.config.permissions.approval_policy.value()), + sandbox_policy: Some(test.config.legacy_sandbox_policy()), + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), + collaboration_mode: Some(turn_mode), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index d1384ff800..2c789a271d 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -88,24 +88,29 @@ fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: String) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); - Op::UserTurn { - environments: None, + Op::UserInput { items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3f6c9cfdc0..f1e01c22ce 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -46,24 +46,31 @@ async fn submit_user_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: collaboration_mode.or({ + Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + }), + ..Default::default() + }, }) .await?; Ok(()) @@ -126,24 +133,29 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run shell command".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 3404edbb1e..085ca96f37 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -113,8 +113,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![ UserInput::LocalImage { path: abs_path.clone(), @@ -125,18 +124,24 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu text_elements: Vec::new(), }, ], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -204,8 +209,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -216,18 +220,24 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> text_elements: Vec::new(), }, ], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 0015a418ca..82e01e2451 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -44,30 +44,28 @@ use std::path::PathBuf; fn disabled_plan_turn( text: &str, - model: String, + _model: String, collaboration_mode: CollaborationMode, ) -> anyhow::Result { let cwd = std::env::current_dir()?; let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); - Ok(Op::UserTurn { - environments: None, + Ok(Op::UserInput { items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(collaboration_mode), - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) } diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index c4ce8ac908..e81d7aa82a 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -75,24 +75,29 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 17fe33dc8b..16e6cbe37d 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -64,27 +64,35 @@ async fn submit_user_turn( approval_policy: AskForApproval, collaboration_mode: Option, ) -> Result<()> { + let session_model = test.session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: collaboration_mode.or({ + Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + }), + ..Default::default() + }, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index adce6e36eb..1c9358a8a3 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -42,21 +42,26 @@ use wiremock::MockServer; fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); - Op::UserTurn { - environments: None, + Op::UserInput { items, + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index d9c636b35f..9cf83424c9 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -117,24 +117,29 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), first_turn_cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: first_turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: first_sandbox_policy, - permission_profile: first_permission_profile, - model: test.session_configured.model.clone(), - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(first_turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(first_sandbox_policy), + permission_profile: first_permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |event| { @@ -147,24 +152,30 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { preturn_context_diff_cwd.as_path(), ); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "second turn with context updates".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: preturn_context_diff_cwd, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: None, - sandbox_policy: second_sandbox_policy, - permission_profile: second_permission_profile, - model: test.session_configured.model.clone(), - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: Some(Personality::Friendly), + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(preturn_context_diff_cwd), + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(second_sandbox_policy), + permission_profile: second_permission_profile, + personality: Some(Personality::Friendly), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |event| { @@ -230,24 +241,29 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R turn_permission_fields(PermissionProfile::read_only(), cwd_one.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "first turn in agents_one".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_one.clone(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: first_sandbox_policy, - permission_profile: first_permission_profile, - model: test.session_configured.model.clone(), - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_one.clone()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(first_sandbox_policy), + permission_profile: first_permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |event| { @@ -258,24 +274,29 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R let (second_sandbox_policy, second_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_two.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "second turn in agents_two".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_two, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: second_sandbox_policy, - permission_profile: second_permission_profile, - model: test.session_configured.model.clone(), - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_two), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(second_sandbox_policy), + permission_profile: second_permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |event| { @@ -377,24 +398,30 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul ); resumed .codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "resume and change personality".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: resume_override_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: resumed.session_configured.model.clone(), - effort: resumed.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: Some(Personality::Friendly), + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(resume_override_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + personality: Some(Personality::Friendly), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: resumed.session_configured.model.clone(), + reasoning_effort: resumed.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 8463ee1cf1..3b729738df 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -92,24 +92,29 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hi".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: codex_protocol::protocol::AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 346c503c25..49f34d8644 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -104,24 +104,29 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch .await; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please run a tool".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index d511c8f0b6..b057b86171 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -113,24 +113,29 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.config.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await .unwrap_or_else(|err| panic!("submit user turn: {err}")); diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 09eb61fa69..8924ca2a25 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -60,24 +60,30 @@ fn read_only_text_turn_with_personality( ) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); - Op::UserTurn { - environments: None, + Op::UserInput { items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + personality, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2023778a11..accf3fb789 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -714,7 +714,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - // Second turn using per-turn overrides via UserTurn + // Second turn using per-turn thread-settings overrides. let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); let permission_profile = PermissionProfile::workspace_write_with( @@ -726,24 +726,24 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, new_cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: new_cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: "o3".to_string(), - effort: Some(ReasoningEffort::High), - summary: Some(ReasoningSummary::Detailed), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(new_cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + model: Some("o3".to_string()), + effort: Some(Some(ReasoningEffort::High)), + summary: Some(ReasoningSummary::Detailed), + ..Default::default() + }, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -840,47 +840,57 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_summary = config.model_reasoning_summary; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.to_path_buf(), - approval_policy: default_approval_policy, - approvals_reviewer: None, - sandbox_policy: default_sandbox_policy.clone(), - permission_profile: None, - model: default_model.clone(), - effort: default_effort, - summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(default_cwd.to_path_buf()), + approval_policy: Some(default_approval_policy), + sandbox_policy: Some(default_sandbox_policy.clone()), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: default_model.clone(), + reasoning_effort: default_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.to_path_buf(), - approval_policy: default_approval_policy, - approvals_reviewer: None, - sandbox_policy: default_sandbox_policy.clone(), - permission_profile: None, - model: default_model.clone(), - effort: default_effort, - summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(default_cwd.to_path_buf()), + approval_policy: Some(default_approval_policy), + sandbox_policy: Some(default_sandbox_policy.clone()), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: default_model.clone(), + reasoning_effort: default_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -969,24 +979,29 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_summary = config.model_reasoning_summary; codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.to_path_buf(), - approval_policy: default_approval_policy, - approvals_reviewer: None, - sandbox_policy: default_sandbox_policy.clone(), - permission_profile: None, - model: default_model, - effort: default_effort, - summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(default_cwd.to_path_buf()), + approval_policy: Some(default_approval_policy), + sandbox_policy: Some(default_sandbox_policy.clone()), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: default_model, + reasoning_effort: default_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -994,24 +1009,30 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, default_cwd.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: "o3".to_string(), - effort: Some(ReasoningEffort::High), - summary: Some(ReasoningSummary::Detailed), - service_tier: None, - collaboration_mode: None, + environments: None, final_output_json_schema: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(default_cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + summary: Some(ReasoningSummary::Detailed), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: "o3".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index e19379ffc8..873796ff6c 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -67,24 +67,29 @@ async fn submit_turn_with_approval_and_environments( environments: Vec, ) -> Result<()> { test.codex - .submit(Op::UserTurn { - environments: Some(environments), + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: Some(environments), final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index d1caf24836..adc73d8a79 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -142,9 +142,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind ) .await; - let TestCodex { - codex, cwd, config, .. - } = test_codex() + let TestCodex { codex, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some(requested_model.to_string()); @@ -154,24 +152,15 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind .await?; codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "check context window".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - model: requested_model.to_string(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - permission_profile: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -220,9 +209,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re ) .await; - let TestCodex { - codex, cwd, config, .. - } = test_codex() + let TestCodex { codex, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some(requested_model.to_string()); @@ -232,24 +219,15 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re .await?; codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "check context window".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - model: requested_model.to_string(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - permission_profile: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -298,9 +276,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re ) .await; - let TestCodex { - codex, cwd, config, .. - } = test_codex() + let TestCodex { codex, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some(requested_model.to_string()); @@ -309,24 +285,15 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re .await?; codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "check context window".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - model: requested_model.to_string(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - permission_profile: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -388,9 +355,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( ) .await; - let TestCodex { - codex, cwd, config, .. - } = test_codex() + let TestCodex { codex, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some(requested_model.to_string()); @@ -399,24 +364,15 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( .await?; codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "check model slug".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - permission_profile: None, - model: requested_model.to_string(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -452,36 +408,21 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() ) .await; - let TestCodex { - codex, cwd, config, .. - } = test_codex() + let TestCodex { codex, .. } = test_codex() .with_model(requested_model) .build(&server) .await?; codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "check namespaced model metadata".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: None, - sandbox_policy: config.legacy_sandbox_policy(), - permission_profile: None, - model: requested_model.to_string(), - effort: None, - summary: Some( - config - .model_reasoning_summary - .unwrap_or(ReasoningSummary::Auto), - ), - service_tier: None, - collaboration_mode: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), }) .await?; @@ -633,24 +574,22 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run call".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: REMOTE_MODEL_SLUG.to_string(), - effort: None, - summary: Some(ReasoningSummary::Auto), - service_tier: None, - collaboration_mode: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + summary: Some(ReasoningSummary::Auto), + ..Default::default() + }, }) .await?; @@ -865,24 +804,22 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello remote".into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: model.to_string(), - effort: None, - summary: Some(ReasoningSummary::Auto), - service_tier: None, - collaboration_mode: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + summary: Some(ReasoningSummary::Auto), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index cf021f29e4..9800104685 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -190,24 +190,30 @@ async fn submit_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy, - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index cb13243966..84e101e73a 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -142,24 +142,30 @@ async fn submit_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer, + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 6ddd9d0b5e..e0ec684797 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -132,36 +132,33 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(CollaborationMode { - mode, - settings: Settings { - model: session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - }, - }), - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -282,31 +279,29 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: session_configured.model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -388,24 +383,22 @@ where turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(collaboration_mode), - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; 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 09e99f78c3..f396a825a1 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -130,24 +130,29 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::workspace_write(), cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7ead0364dc..7c811a88e0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -105,24 +105,29 @@ fn read_only_user_turn_with_model( let cwd = fixture.cwd.path().to_path_buf(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); - Op::UserTurn { + Op::UserInput { items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), }], - final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index c937d49ad4..ab985ce0f7 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -37,24 +37,29 @@ const CYBER_POLICY_MESSAGE: &str = fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); - Op::UserTurn { - environments: None, + Op::UserInput { items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: REQUESTED_MODEL.to_string(), - effort: test.config.model_reasoning_effort, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: REQUESTED_MODEL.to_string(), + reasoning_effort: test.config.model_reasoning_effort, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 49ef4956cd..14241ffafb 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -159,24 +159,29 @@ async fn run_snapshot_command_with_options( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -254,24 +259,29 @@ async fn run_shell_command_snapshot_with_options( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -329,24 +339,29 @@ async fn run_tool_turn_on_harness( let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -567,24 +582,29 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd.clone(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.clone()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 72244d534e..ad8ad6a91a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -46,24 +46,29 @@ async fn submit_turn_with_policies( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd_path().to_path_buf()), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 6434f9c533..9173f60c4a 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -74,8 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![ UserInput::Text { text: "please use $demo".to_string(), @@ -86,18 +85,24 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { path: skill_path.clone(), }, ], + environments: None, final_output_json_schema: None, - cwd: test.config.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index ab0b4c27ea..f576e55a3d 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,24 +405,29 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "call the rmcp echo tool".to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: test.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 38843f2d83..2227e5b34e 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -102,24 +102,29 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please run the shell command".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -178,24 +183,29 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -264,24 +274,29 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -360,24 +375,29 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -493,24 +513,29 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd_path, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd_path), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 47282c283d..6663991e02 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -37,24 +37,29 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -358,24 +363,29 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "stream delayed completion".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 447bca6731..be9267934d 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -515,24 +515,29 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { fixture .codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "call the rmcp image tool".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: fixture.cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile: Some(permission_profile), - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(fixture.cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile: Some(permission_profile), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6b962116af..98755201c9 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -192,24 +192,29 @@ async fn submit_unified_exec_turn( turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: test.config.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -278,24 +283,29 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, &cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "apply patch via unified exec".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2120,24 +2130,29 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2218,24 +2233,29 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2685,25 +2705,29 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "summarize large output".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - // Important! - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2803,24 +2827,29 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "read the fixture files".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; @@ -2936,24 +2965,29 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "start python under seatbelt".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: turn_cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(turn_cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 4fe3586d7f..536ee7b495 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -174,24 +174,29 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> fixture .codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "run model shell command".to_string(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd, - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: fixture.session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: fixture.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index d78e8761fd..e631e537c6 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -72,21 +72,26 @@ const VIEW_IMAGE_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); - Op::UserTurn { - environments: None, + Op::UserInput { items, + environments: None, final_output_json_schema: None, - cwd: test.config.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, } } diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index f073ce7ab8..32a6e30caf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -152,24 +152,29 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserTurn { - environments: None, + .submit(Op::UserInput { items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], + environments: None, final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_configured.model.clone(), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(cwd.path().to_path_buf()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 77d862086c..cec929b07f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,74 +518,6 @@ pub enum Op { thread_settings: ThreadSettingsOverrides, }, - /// Similar to [`Op::UserInput`], but contains additional context required - /// for a turn of a [`crate::codex_thread::CodexThread`]. - UserTurn { - /// User input items, see `InputItem` - items: Vec, - - /// `cwd` to use with the [`SandboxPolicy`] and potentially tool calls - /// such as `local_shell`. - cwd: PathBuf, - - /// Policy to use for command approval. - approval_policy: AskForApproval, - - /// Reviewer to use for approval requests raised during this turn. - /// - /// When omitted, the session keeps the current setting - approvals_reviewer: Option, - - /// Policy to use for tool calls such as `local_shell`. - sandbox_policy: SandboxPolicy, - - /// Full permissions profile to use for tool calls such as `local_shell`. - /// - /// When omitted, `sandbox_policy` is used as a legacy compatibility - /// projection. - #[serde(default, skip_serializing_if = "Option::is_none")] - permission_profile: Option, - - /// Must be a valid model slug for the configured client session - /// associated with this conversation. - model: String, - - /// Will only be honored if the model is configured to use reasoning. - #[serde(skip_serializing_if = "Option::is_none")] - effort: Option, - - /// Will only be honored if the model is configured to use reasoning. - /// - /// When omitted, the session keeps the current setting (which allows core to - /// fall back to the selected model's default on new sessions). - #[serde(default, skip_serializing_if = "Option::is_none")] - summary: Option, - - /// Optional service tier override for this turn. - /// - /// Use `Some(Some(_))` to set a specific tier for this turn, `Some(None)` to - /// explicitly clear the tier for this turn, or `None` to keep the existing - /// session preference. - #[serde(default, skip_serializing_if = "Option::is_none")] - service_tier: Option>, - - // The JSON schema to use for the final assistant message - final_output_json_schema: Option, - - /// EXPERIMENTAL - set a pre-set collaboration mode. - /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] - collaboration_mode: Option, - - /// Optional personality override for this turn. - #[serde(skip_serializing_if = "Option::is_none")] - personality: Option, - - /// Optional turn-scoped environments. - #[serde(default, skip_serializing_if = "Option::is_none")] - environments: Option>, - }, - /// Inter-agent communication that should be recorded as assistant history /// while still using the normal thread submission lifecycle. InterAgentCommunication { @@ -843,7 +775,6 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", - Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", From 9e9a62dc2899ce4fc3321617f3d26a8bebd8dad8 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 20:33:27 -0700 Subject: [PATCH 093/114] [codex] Extract turn skill and plugin injections (#23396) ## Why `run_turn` had accumulated the turn-scoped skill, plugin, app, MCP, connector-selection, and analytics setup inline. That made the orchestration path harder to scan even though the actual turn item injection still needs to stay in `run_turn` so ordering is explicit. ## What changed This extracts that setup into `build_skills_and_plugins`, which returns the combined injection `ResponseItem`s and the explicitly enabled connector IDs. `run_turn` now keeps the required orchestration pieces: context update recording, user input handling, connector selection merge, and the explicit per-item `record_conversation_items` calls for injection items. The refactor keeps the change LOC-neutral in `core/src/session/turn.rs` and preserves the existing response-item based injection path. ## Validation - `cargo test -p codex-core collect_explicit_app_ids_from_skill_items` - `just fix -p codex-core` --- codex-rs/core/src/session/turn.rs | 280 +++++++++++++++--------------- 1 file changed, 140 insertions(+), 140 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8edf2e4785..9fe7f93dc3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -134,10 +134,6 @@ use tracing::warn; /// - If the model sends only an assistant message, we record it in the /// conversation history and consider the turn complete. /// -#[expect( - clippy::await_holding_invalid_type, - reason = "turn execution must keep active-turn state transitions atomic" -)] pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, @@ -172,131 +168,11 @@ pub(crate) async fn run_turn( return None; } - let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); - sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; - let loaded_plugins = sess - .services - .plugins_manager - .plugins_for_config(&turn_context.config.plugins_config_input()) - .await; - // Structured plugin:// mentions are resolved from the current session's - // enabled plugins, then converted into turn-scoped guidance below. - let mentioned_plugins = - collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); - let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { - // Plugin mentions need raw MCP/app inventory even when app tools - // are normally hidden so we can describe the plugin's currently - // usable capabilities for this turn. - match sess - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .or_cancel(&cancellation_token) - .await - { - Ok(mcp_tools) => mcp_tools, - Err(_) if turn_context.apps_enabled() => return None, - Err(_) => Vec::new(), - } - } else { - Vec::new() - }; - let available_connectors = if turn_context.apps_enabled() { - let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( - loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0), - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - ); - connectors::with_app_enabled_state(connectors, &turn_context.config) - } else { - Vec::new() - }; - let connector_slug_counts = build_connector_slug_counts(&available_connectors); - let skill_name_counts_lower = skills_outcome - .as_ref() - .map_or_else(HashMap::new, |outcome| { - build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 - }); - let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| { - collect_explicit_skill_mentions( - &input, - &outcome.skills, - &outcome.disabled_paths, - &connector_slug_counts, - ) - }); - maybe_prompt_and_install_mcp_dependencies( - sess.as_ref(), - turn_context.as_ref(), - &cancellation_token, - &mentioned_skills, - Some(sess.mcp_elicitation_reviewer()), - ) - .await; - - let session_telemetry = turn_context.session_telemetry.clone(); - let thread_id = sess.conversation_id.to_string(); - let tracking = build_track_events_context( - turn_context.model_info.slug.clone(), - thread_id, - turn_context.sub_id.clone(), - ); - let SkillInjections { - items: skill_injections, - warnings: skill_warnings, - } = build_skill_injections( - &mentioned_skills, - skills_outcome, - Some(&session_telemetry), - &sess.services.analytics_events_client, - tracking.clone(), - ) - .await; - - for message in skill_warnings { - sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) - .await; - } - - let skill_items: Vec = skill_injections - .iter() - .map(|skill| ContextualUserFragment::into(crate::context::SkillInstructions::from(skill))) - .collect(); - - let plugin_items = - build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mentioned_plugin_metadata = mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - .collect::>(); - - let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input); - explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items( - &skill_items, - &available_connectors, - &skill_name_counts_lower, - )); - let connector_names_by_id = available_connectors - .iter() - .map(|connector| (connector.id.as_str(), connector.name.as_str())) - .collect::>(); - let mentioned_app_invocations = explicitly_enabled_connectors - .iter() - .map(|connector_id| AppInvocation { - connector_id: Some(connector_id.clone()), - app_name: connector_names_by_id - .get(connector_id.as_str()) - .map(|name| (*name).to_string()), - invocation_type: Some(InvocationType::Explicit), - }) - .collect::>(); + let (injection_items, explicitly_enabled_connectors) = + build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; if run_pending_session_start_hooks(&sess, &turn_context).await { return None; @@ -325,14 +201,6 @@ pub(crate) async fn run_turn( .await; user_prompt_submit_outcome.additional_contexts }; - sess.services - .analytics_events_client - .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugin_metadata { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); - } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; @@ -346,12 +214,8 @@ pub(crate) async fn run_turn( })) .await; } - if !skill_items.is_empty() { - sess.record_conversation_items(&turn_context, &skill_items) - .await; - } - if !plugin_items.is_empty() { - sess.record_conversation_items(&turn_context, &plugin_items) + for response_item in injection_items { + sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item)) .await; } @@ -698,6 +562,142 @@ pub(crate) async fn run_turn( last_agent_message } +#[expect( + clippy::await_holding_invalid_type, + reason = "MCP tool listing borrows the read guard across cancellation-aware await" +)] +async fn build_skills_and_plugins( + sess: &Arc, + turn_context: &TurnContext, + input: &[UserInput], + cancellation_token: &CancellationToken, +) -> Option<(Vec, HashSet)> { + let tracking = build_track_events_context( + turn_context.model_info.slug.clone(), + sess.conversation_id.to_string(), + turn_context.sub_id.clone(), + ); + let loaded_plugins = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config.plugins_config_input()) + .await; + // Structured plugin:// mentions are resolved from the current session's + // enabled plugins, then converted into turn-scoped guidance below. + let mentioned_plugins = + collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries()); + let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { + // Plugin mentions need raw MCP/app inventory even when app tools + // are normally hidden so we can describe the plugin's currently + // usable capabilities for this turn. + match sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .or_cancel(cancellation_token) + .await + { + Ok(mcp_tools) => mcp_tools, + Err(_) if turn_context.apps_enabled() => return None, + Err(_) => Vec::new(), + } + } else { + Vec::new() + }; + let available_connectors = if turn_context.apps_enabled() { + let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( + loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0), + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + ); + connectors::with_app_enabled_state(connectors, &turn_context.config) + } else { + Vec::new() + }; + let skills_outcome = turn_context.turn_skills.outcome.as_ref(); + let connector_slug_counts = build_connector_slug_counts(&available_connectors); + let skill_name_counts_lower = + build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; + let mentioned_skills = collect_explicit_skill_mentions( + input, + &skills_outcome.skills, + &skills_outcome.disabled_paths, + &connector_slug_counts, + ); + maybe_prompt_and_install_mcp_dependencies( + sess, + turn_context, + cancellation_token, + &mentioned_skills, + Some(sess.mcp_elicitation_reviewer()), + ) + .await; + + let SkillInjections { + items: skill_injections, + warnings: skill_warnings, + } = build_skill_injections( + &mentioned_skills, + Some(skills_outcome), + Some(&turn_context.session_telemetry), + &sess.services.analytics_events_client, + tracking.clone(), + ) + .await; + + for message in skill_warnings { + sess.send_event(turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + + let skill_items: Vec = skill_injections + .iter() + .map(|skill| ContextualUserFragment::into(crate::context::SkillInstructions::from(skill))) + .collect(); + let skill_connector_ids = collect_explicit_app_ids_from_skill_items( + &skill_items, + &available_connectors, + &skill_name_counts_lower, + ); + let plugin_items = + build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(input); + explicitly_enabled_connectors.extend(skill_connector_ids); + let connector_names_by_id = available_connectors + .iter() + .map(|connector| (connector.id.as_str(), connector.name.as_str())) + .collect::>(); + let mentioned_app_invocations = explicitly_enabled_connectors + .iter() + .map(|connector_id| AppInvocation { + connector_id: Some(connector_id.clone()), + app_name: connector_names_by_id + .get(connector_id.as_str()) + .map(|name| (*name).to_string()), + invocation_type: Some(InvocationType::Explicit), + }) + .collect::>(); + sess.services + .analytics_events_client + .track_app_mentioned(tracking.clone(), mentioned_app_invocations); + for plugin in mentioned_plugins + .iter() + .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } + + let mut injection_items = skill_items; + injection_items.extend(plugin_items); + Some((injection_items, explicitly_enabled_connectors)) +} + async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, From d3d38159eddc00f6dec46f375e8a5e94d246dbeb Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 21:02:30 -0700 Subject: [PATCH 094/114] fix(plugins): keep version upgrades additive (#23356) ## Why Windows can reject plugin cache upgrades when a running MCP server still has its working directory inside the currently active plugin version. The existing cache refresh path replaces `plugins/cache//` as a whole, so a live handle under the old version can make an otherwise ordinary version bump fail. This PR keeps the existing plugin-selection model intact while making version bumps less disruptive. ## What changed - When installing a new version beside an existing plugin cache root, move only the staged version directory into place instead of replacing the whole plugin root. - Best-effort prune older sibling version directories after the new version is activated. - Preserve the existing whole-root replacement path for first installs and same-version refreshes. - Add regression coverage for upgrading from `1.0.0` to `2.0.0` without replacing the plugin root. ## Verification - `cargo test -p codex-core-plugins install_with_new_version` - `cargo fmt --package codex-core-plugins --check` --- codex-rs/Cargo.lock | 1 + codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/store.rs | 59 ++++++++++++++++++- codex-rs/core-plugins/src/store_tests.rs | 72 +++++++++++++++++++++++- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ced1803fa..2cbc80c513 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2636,6 +2636,7 @@ dependencies = [ "libc", "pretty_assertions", "reqwest", + "semver", "serde", "serde_json", "tar", diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 352d6e5714..83df070dd3 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -32,6 +32,7 @@ chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } reqwest = { workspace = true } +semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tar = { workspace = true } diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index fe662a142e..19c3b0116f 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -4,8 +4,10 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; +use semver::Version; use serde::Deserialize; use serde_json::Value as JsonValue; +use std::cmp::Ordering; use std::fs; use std::io; use std::path::Path; @@ -75,7 +77,7 @@ impl PluginStore { }) .filter(|version| validate_plugin_version_segment(version).is_ok()) .collect::>(); - discovered_versions.sort_unstable(); + discovered_versions.sort_unstable_by(|left, right| compare_plugin_versions(left, right)); if discovered_versions.is_empty() { None } else if discovered_versions @@ -286,6 +288,15 @@ fn replace_plugin_root_atomically( let staged_version_root = staged_root.join(plugin_version); copy_dir_recursive(source, &staged_version_root)?; + let target_version_root = target_root.join(plugin_version); + if target_root.exists() && !target_version_root.exists() { + fs::rename(&staged_version_root, &target_version_root).map_err(|err| { + PluginStoreError::io("failed to activate updated plugin cache version", err) + })?; + remove_old_plugin_versions(target_root, plugin_version)?; + return Ok(()); + } + if target_root.exists() { let backup_dir = tempfile::Builder::new() .prefix("plugin-backup-") @@ -322,6 +333,52 @@ fn replace_plugin_root_atomically( Ok(()) } +fn remove_old_plugin_versions( + target_root: &Path, + plugin_version: &str, +) -> Result<(), PluginStoreError> { + let Ok(entries) = fs::read_dir(target_root) else { + return Ok(()); + }; + + for entry in entries.filter_map(Result::ok) { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let Ok(version) = entry.file_name().into_string() else { + continue; + }; + if version == plugin_version || validate_plugin_version_segment(&version).is_err() { + continue; + } + + if fs::remove_dir_all(entry.path()).is_err() + && old_plugin_version_would_stay_active(&version, plugin_version) + { + return Err(PluginStoreError::Invalid(format!( + "failed to activate updated plugin cache version `{plugin_version}` while `{version}` remains active" + ))); + } + } + + Ok(()) +} + +fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool { + old_version == DEFAULT_PLUGIN_VERSION + || compare_plugin_versions(old_version, new_version).is_gt() +} + +fn compare_plugin_versions(left: &str, right: &str) -> Ordering { + match (Version::parse(left), Version::parse(right)) { + (Ok(left), Ok(right)) => left.cmp(&right), + _ => left.cmp(right), + } +} + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> { fs::create_dir_all(target) .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?; diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 0ba6b0d2c6..200055fe6c 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -247,7 +247,7 @@ fn active_plugin_version_prefers_default_local_version_when_multiple_versions_ex } #[test] -fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { +fn active_plugin_version_returns_latest_version_when_default_is_missing() { let tmp = tempdir().unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), @@ -268,6 +268,76 @@ fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { ); } +#[test] +fn active_plugin_version_compares_semver_versions_semantically() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/9.0.0", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/10.0.0", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("10.0.0".to_string()) + ); +} + +#[test] +fn install_with_new_version_keeps_existing_plugin_root_and_prunes_old_versions() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + write_plugin_with_version(tmp.path(), "v1", "sample-plugin", Some("1.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v1")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + write_plugin_with_version(tmp.path(), "v2", "sample-plugin", Some("2.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v2")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("2.0.0".to_string()) + ); + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/2.0.0") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin/1.0.0") + .exists() + ); +} + +#[test] +fn old_plugin_version_would_stay_active_for_local_or_later_versions() { + assert!(old_plugin_version_would_stay_active( + DEFAULT_PLUGIN_VERSION, + "1.0.0" + )); + assert!(old_plugin_version_would_stay_active("10.0.0", "9.0.0")); + assert!(!old_plugin_version_would_stay_active("1.0.0", "2.0.0")); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); From a668379abf0f67d81a61dc971ea463c483846fd2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 21:03:51 -0700 Subject: [PATCH 095/114] [5 of 7] Replace OverrideTurnContext with ThreadSettings (#22508) **Stack position:** [5 of 7] ## Summary This PR adds `Op::ThreadSettings`, a queued settings-only update mechanism for changing stored thread settings without starting a new turn. It also removes the legacy `Op::OverrideTurnContext` in the same layer, so reviewers can see the replacement and deletion together. ## Changes - Add `Op::ThreadSettings` for settings-only queued updates. - Emit `ThreadSettingsApplied` with the effective thread settings snapshot after core applies an update. - Route settings-only updates through the same submission queue as user input. - Migrate remaining `OverrideTurnContext` tests and callers to the queued `Op::ThreadSettings` path. - Delete `Op::OverrideTurnContext` from the core protocol and submission loop. This stack addresses #20656 and #22090. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) (this PR) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- .../thread_processor_tests.rs | 12 + .../src/request_processors/turn_processor.rs | 2 +- codex-rs/core/src/codex_thread.rs | 21 +- codex-rs/core/src/session/handlers.rs | 245 ++++++++------- codex-rs/core/src/session/mod.rs | 9 +- codex-rs/core/src/session/session.rs | 2 + codex-rs/core/src/session/tests.rs | 70 ++--- codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/tests/common/lib.rs | 25 ++ .../tests/suite/collaboration_instructions.rs | 287 ++++++------------ codex-rs/core/tests/suite/compact.rs | 24 +- codex-rs/core/tests/suite/compact_remote.rs | 44 +-- .../core/tests/suite/compact_resume_fork.rs | 23 +- codex-rs/core/tests/suite/model_overrides.rs | 50 ++- codex-rs/core/tests/suite/model_switching.rs | 65 ++-- .../core/tests/suite/model_visible_layout.rs | 22 +- codex-rs/core/tests/suite/override_updates.rs | 74 ++--- .../core/tests/suite/permissions_messages.rs | 90 ++---- codex-rs/core/tests/suite/personality.rs | 88 ++---- codex-rs/core/tests/suite/prompt_caching.rs | 39 +-- codex-rs/core/tests/suite/remote_models.rs | 44 +-- codex-rs/core/tests/suite/resume.rs | 23 +- codex-rs/core/tests/suite/review.rs | 24 +- codex-rs/docs/protocol_v1.md | 2 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/memories/write/src/startup_tests.rs | 22 +- codex-rs/protocol/src/protocol.rs | 110 +++---- codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + 29 files changed, 553 insertions(+), 869 deletions(-) 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 f9841421ca..aa33fc623f 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 @@ -62,6 +62,9 @@ mod thread_processor_behavior_tests { use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -662,7 +665,16 @@ mod thread_processor_behavior_tests { profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, + reasoning_summary: None, personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, session_source: SessionSource::Cli, thread_source: None, }; 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 d383f64d4e..71715e5079 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -479,7 +479,7 @@ impl TurnRequestProcessor { // still queued together with the input below to preserve submission order. if has_any_overrides { thread - .validate_thread_settings_overrides(CodexThreadSettingsOverrides { + .preview_thread_settings_overrides(CodexThreadSettingsOverrides { cwd: cwd.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index aff76b8c02..1b40387c3f 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -63,7 +63,9 @@ pub struct ThreadConfigSnapshot { pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, + pub reasoning_summary: Option, pub personality: Option, + pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, pub thread_source: Option, } @@ -257,11 +259,19 @@ impl CodexThread { .await } - /// Validate persistent thread settings overrides without committing them. - pub async fn validate_thread_settings_overrides( + /// Preview persistent thread settings overrides without committing them. + pub async fn preview_thread_settings_overrides( &self, overrides: CodexThreadSettingsOverrides, - ) -> ConstraintResult<()> { + ) -> ConstraintResult { + let updates = self.thread_settings_update(overrides).await; + self.codex.session.preview_settings(&updates).await + } + + async fn thread_settings_update( + &self, + overrides: CodexThreadSettingsOverrides, + ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { cwd, workspace_roots, @@ -289,7 +299,7 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, workspace_roots, profile_workspace_roots, @@ -304,8 +314,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index e01ee28f99..3d57bd7074 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -42,7 +42,9 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -81,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) { .await; } -pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) { - if let Err(err) = sess.update_settings(updates).await { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }) - .await; - } -} - pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { user_input_or_turn_inner( sess, @@ -104,36 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { .await; } +pub async fn update_thread_settings( + sess: &Arc, + sub_id: String, + thread_settings: ThreadSettingsOverrides, +) { + let updates = thread_settings_update(sess, thread_settings).await; + let msg = match sess.update_settings(updates).await { + Ok(()) => thread_settings_applied_event(sess).await, + Err(err) => EventMsg::Error(ErrorEvent { + message: format!("invalid thread settings override: {err}"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(Event { id: sub_id, msg }).await; +} + +async fn thread_settings_update( + sess: &Session, + thread_settings: ThreadSettingsOverrides, +) -> SessionSettingsUpdate { + let ThreadSettingsOverrides { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = thread_settings; + let collaboration_mode = match collaboration_mode { + Some(collaboration_mode) => collaboration_mode, + None => { + let state = sess.state.lock().await; + // Model and reasoning effort live in CollaborationMode settings today, so + // partial thread-settings updates refresh those fields on the active mode. + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, /*developer_instructions*/ None) + } + }; + SessionSettingsUpdate { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + } +} + +async fn thread_settings_applied_event(sess: &Session) -> EventMsg { + let snapshot = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { + thread_settings: ThreadSettingsSnapshot { + model: snapshot.model, + model_provider_id: snapshot.model_provider_id, + service_tier: snapshot.service_tier, + approval_policy: snapshot.approval_policy, + approvals_reviewer: snapshot.approvals_reviewer, + permission_profile: snapshot.permission_profile, + active_permission_profile: snapshot.active_permission_profile, + cwd: snapshot.cwd, + reasoning_effort: snapshot.reasoning_effort, + reasoning_summary: snapshot.reasoning_summary, + personality: snapshot.personality, + collaboration_mode: snapshot.collaboration_mode, + }, + }) +} + pub(super) async fn user_input_or_turn_inner( sess: &Arc, sub_id: String, op: Op, mirror_user_text_to_realtime: Option<()>, ) { - let (items, updates, responsesapi_client_metadata) = match op { - Op::UserInput { - items, - environments, - final_output_json_schema, - responsesapi_client_metadata, - thread_settings, - } => { - let mut updates = if thread_settings == ThreadSettingsOverrides::default() { - SessionSettingsUpdate::default() - } else { - thread_settings_update(sess, thread_settings).await - }; - updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; - (items, updates, responsesapi_client_metadata) - } - _ => unreachable!(), + let Op::UserInput { + items, + environments, + final_output_json_schema, + responsesapi_client_metadata, + thread_settings, + } = op + else { + unreachable!(); }; + let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default(); + let mut updates = if emit_thread_settings_applied { + thread_settings_update(sess, thread_settings).await + } else { + 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. return; }; + if emit_thread_settings_applied { + sess.send_event_raw(Event { + id: sub_id.clone(), + msg: thread_settings_applied_event(sess).await, + }) + .await; + } sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; let accepted_items = match sess @@ -183,56 +268,6 @@ pub(super) async fn user_input_or_turn_inner( } } -async fn thread_settings_update( - sess: &Session, - thread_settings: ThreadSettingsOverrides, -) -> SessionSettingsUpdate { - let ThreadSettingsOverrides { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } = thread_settings; - let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { - collaboration_mode - } else { - let state = sess.state.lock().await; - // Model and reasoning effort live in CollaborationMode settings today, so - // partial thread-settings updates refresh those fields on the active mode. - state - .session_configuration - .collaboration_mode - .with_updates(model, effort, /*developer_instructions*/ None) - }; - SessionSettingsUpdate { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - collaboration_mode: Some(collaboration_mode), - reasoning_summary: summary, - service_tier, - personality, - ..Default::default() - } -} - async fn mirror_user_text_to_realtime(sess: &Arc, items: &[UserInput]) { let text = UserMessageItem::new(items).message(); if text.is_empty() { @@ -729,54 +764,14 @@ pub(super) async fn submission_loop( realtime_conversation_list_voices(&sess, sub.id.clone()).await; false } - Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => { - let collaboration_mode = if let Some(collab_mode) = collaboration_mode { - collab_mode - } else { - let state = sess.state.lock().await; - state.session_configuration.collaboration_mode.with_updates( - model.clone(), - effort, - /*developer_instructions*/ None, - ) - }; - override_turn_context( - &sess, - sub.id.clone(), - SessionSettingsUpdate { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - collaboration_mode: Some(collaboration_mode), - reasoning_summary: summary, - service_tier, - personality, - ..Default::default() - }, - ) - .await; - false - } Op::UserInput { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } + Op::ThreadSettings { thread_settings } => { + update_thread_settings(&sess, sub.id.clone(), thread_settings).await; + false + } Op::InterAgentCommunication { communication } => { inter_agent_communication(&sess, sub.id.clone(), communication).await; false diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7c3b4803dd..cb848286c9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1384,12 +1384,15 @@ impl Session { Ok(()) } - pub(crate) async fn validate_settings( + pub(crate) async fn preview_settings( &self, updates: &SessionSettingsUpdate, - ) -> ConstraintResult<()> { + ) -> ConstraintResult { let state = self.state.lock().await; - state.session_configuration.apply(updates).map(|_| ()) + state + .session_configuration + .apply(updates) + .map(|configuration| configuration.thread_config_snapshot()) } pub(crate) async fn set_session_startup_prewarm( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 082308f20d..2d96cf9165 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -178,7 +178,9 @@ impl SessionConfiguration { profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), + reasoning_summary: self.model_reasoning_summary, personality: self.personality, + collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4f090a8a3a..2363f02cc6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -118,6 +118,7 @@ use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -2257,24 +2258,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< developer_instructions: Some("Fork turn collaboration instructions.".to_string()), }, }; - forked - .thread - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; - forked .thread .submit(Op::UserInput { @@ -2285,7 +2268,11 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, - thread_settings: Default::default(), + thread_settings: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2338,7 +2325,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let turn_id = previous_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2521,14 +2508,14 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context let first_turn_id = first_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let mut rolled_back_context_item = first_context_item.clone(); rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string()); rolled_back_context_item.model = "rolled-back-model".to_string(); let rolled_back_turn_id = rolled_back_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let turn_one_user = user_message("turn 1 user"); let turn_one_assistant = assistant_message("turn 1 assistant"); let turn_two_user = user_message("turn 2 user"); @@ -2637,7 +2624,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio let first_turn_id = first_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let compact_turn_id = "compact-turn".to_string(); let rolled_back_turn_id = "rolled-back-turn".to_string(); let compacted_history = vec![ @@ -4833,7 +4820,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -4911,7 +4898,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -5008,7 +4995,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -5198,24 +5185,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { #[test] fn op_kind_for_input_and_context_ops() { - assert_eq!( - Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - } - .kind(), - "override_turn_context" - ); assert_eq!( Op::UserInput { environments: None, @@ -5227,6 +5196,13 @@ fn op_kind_for_input_and_context_ops() { .kind(), "user_input" ); + assert_eq!( + Op::ThreadSettings { + thread_settings: ThreadSettingsOverrides::default(), + } + .kind(), + "thread_settings" + ); } #[tokio::test] @@ -6798,7 +6774,7 @@ async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_develo .session_configuration .session_source = session_source.clone(); Arc::get_mut(&mut turn_context) - .expect("turn context should not be shared") + .expect("thread settings should not be shared") .session_source = session_source; let initial_context = session.build_initial_context(turn_context.as_ref()).await; @@ -7064,7 +7040,7 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o #[tokio::test] async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_builds() { let (session, turn_context, rx) = make_session_and_context_with_rx().await; - let mut turn_context = Arc::into_inner(turn_context).expect("sole turn context owner"); + let mut turn_context = Arc::into_inner(turn_context).expect("sole thread settings owner"); let mut outcome = SkillLoadOutcome::default(); outcome.skills = vec![ SkillMetadata { @@ -9890,7 +9866,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { // The rejection should not poison the non-escalated path for the same // command. Force DangerFullAccess so this check stays focused on approval // policy rather than platform-specific sandbox behavior. - let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc"); + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9fe7f93dc3..24788d4d91 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1476,6 +1476,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TurnComplete(_) | EventMsg::TokenCount(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index ad7858f804..194fb2ae0a 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -248,6 +248,31 @@ where wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await } +pub async fn submit_thread_settings( + codex: &CodexThread, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides, +) -> anyhow::Result<()> { + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::Op; + use tokio::time::Duration; + use tokio::time::timeout; + + let submission_id = codex.submit(Op::ThreadSettings { thread_settings }).await?; + loop { + let ev = timeout(Duration::from_secs(10), codex.next_event()) + .await + .expect("timeout waiting for thread settings update") + .expect("stream ended unexpectedly"); + if ev.id == submission_id { + match ev.msg { + EventMsg::ThreadSettingsApplied(_) => return Ok(()), + EventMsg::Error(err) => panic!("thread settings update failed: {}", err.message), + other => panic!("unexpected thread settings update event: {other:?}"), + } + } + } +} + pub async fn wait_for_event_match(codex: &CodexThread, matcher: F) -> T where F: Fn(&codex_protocol::protocol::EventMsg) -> Option, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bd01dc4e7d..bf8de53dd1 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -123,22 +123,14 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu let collab_text = "collab instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -277,22 +269,14 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re let collab_text = "override instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -333,22 +317,14 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu let turn_text = "turn override"; let turn_mode = collab_mode_with_instructions(Some(turn_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(base_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -405,22 +381,14 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> let first_text = "first instructions"; let second_text = "second instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -436,22 +404,14 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -496,22 +456,14 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { let test = test_codex().build(&server).await?; let collab_text = "same instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -527,22 +479,14 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -586,25 +530,17 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang let default_text = "default mode instructions"; let plan_text = "plan mode instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(default_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -620,25 +556,17 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Plan, Some(plan_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -683,25 +611,17 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() let test = test_codex().build(&server).await?; let collab_text = "mode-stable instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -717,25 +637,17 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -785,23 +697,14 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { let home = initial.home.clone(); let collab_text = "resume instructions"; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex @@ -856,18 +759,9 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { let test = test_codex().build(&server).await?; let current_model = test.session_configured.model.clone(); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -876,9 +770,10 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { developer_instructions: Some("".to_string()), }, }), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 2c789a271d..b07adfc61c 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3279,23 +3279,15 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; } - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("override turn context"); + ..Default::default() + }, + ) + .await + .expect("override thread settings"); let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" .to_string(); codex diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 9db94016e6..9aac846ffe 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -2773,22 +2773,14 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us for user in ["USER_ONE", "USER_TWO", "USER_THREE"] { if user == "USER_THREE" { - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; } codex .submit(Op::UserInput { @@ -2891,22 +2883,14 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; codex .submit(Op::UserInput { environments: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 20ae213b26..b2b02f5e66 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -508,7 +508,7 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -/// Scenario: rolling back a turn that introduced persistent thread settings +/// Scenario: rolling back a turn that introduced persistent pre-thread settings /// diffs should trim those context updates so the next request includes them /// only once. async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { @@ -548,18 +548,10 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); std::fs::create_dir_all(&override_cwd)?; - conversation - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &conversation, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(override_cwd.to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -568,9 +560,10 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()), }, }), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; user_turn(&conversation, TURN_TWO_USER).await; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index 9f8835f19f..f70c0ae1c2 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -9,7 +9,7 @@ use pretty_assertions::assert_eq; const CONFIG_TOML: &str = "config.toml"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_does_not_persist_when_config_exists() { +async fn thread_settings_update_does_not_persist_when_config_exists() { let server = start_mock_server().await; let initial_contents = "model = \"gpt-4o\"\n"; let mut builder = test_codex() @@ -24,23 +24,16 @@ async fn override_turn_context_does_not_persist_when_config_exists() { let codex = test.codex.clone(); let config_path = test.home.path().join(CONFIG_TOML); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("submit override"); + ..Default::default() + }, + ) + .await + .expect("submit override"); codex.submit(Op::Shutdown).await.expect("request shutdown"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -52,7 +45,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_does_not_create_config_file() { +async fn thread_settings_update_does_not_create_config_file() { let server = start_mock_server().await; let mut builder = test_codex(); let test = builder.build(&server).await.expect("create conversation"); @@ -63,23 +56,16 @@ async fn override_turn_context_does_not_create_config_file() { "test setup should start without config" ); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("submit override"); + ..Default::default() + }, + ) + .await + .expect("submit override"); codex.submit(Op::Shutdown).await.expect("request shutdown"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 1c9358a8a3..009dfda60f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -161,22 +161,14 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( @@ -241,22 +233,15 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( @@ -988,22 +973,14 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< ); wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(smaller_model_slug.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 9cf83424c9..17bc0f6703 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -501,23 +501,15 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; - resumed - .codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &resumed.codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(resume_override_cwd), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, model: Some("gpt-5.2".to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; resumed .codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index d0e949947a..ce7c87a93b 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -24,7 +24,7 @@ fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMod } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_permissions_update() -> Result<()> +async fn thread_settings_update_without_user_turn_does_not_record_permissions_update() -> Result<()> { skip_if_no_network!(Ok(())); @@ -34,22 +34,14 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd }); let test = builder.build(&server).await?; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -64,7 +56,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_environment_update() -> Result<()> +async fn thread_settings_update_without_user_turn_does_not_record_environment_update() -> Result<()> { skip_if_no_network!(Ok(())); @@ -72,22 +64,14 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd let test = test_codex().build(&server).await?; let new_cwd = TempDir::new()?; - test.codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(new_cwd.path().to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -102,8 +86,8 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_collaboration_update() -> Result<()> -{ +async fn thread_settings_update_without_user_turn_does_not_record_collaboration_update() +-> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -111,22 +95,14 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u let collab_text = "override collaboration instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 33d494ae45..b77e65c0f5 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -103,22 +103,14 @@ async fn permissions_message_added_on_override_change() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -240,22 +232,14 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -330,23 +314,14 @@ async fn resume_replays_permissions_messages() -> Result<()> { .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex @@ -439,23 +414,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8924ca2a25..bbe8178f8d 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -333,22 +333,14 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Friendly), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -417,22 +409,14 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -514,22 +498,14 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -763,22 +739,14 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Friendly), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index accf3fb789..68b3816ae6 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -442,22 +442,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let sandbox_policy = permission_profile .to_legacy_sandbox_policy(config.cwd.as_path()) .expect("workspace profile should have legacy projection"); - codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), - windows_sandbox_level: None, - model: None, effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; // Second turn after overrides codex @@ -493,7 +489,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - // After overriding thread settings, emit one updated permissions message. + // After overriding the thread settings, emit one updated permissions message. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, @@ -529,22 +525,17 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }, }; - codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, model: Some("gpt-5.4".to_string()), effort: Some(Some(ReasoningEffort::Low)), - summary: None, - service_tier: None, collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index adc73d8a79..e143cb8e3e 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -534,22 +534,14 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { .await; assert_eq!(model_info.shell_type, ConfigShellToolType::UnifiedExec); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(REMOTE_MODEL_SLUG.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let call_id = "call"; let args = json!({ @@ -783,22 +775,14 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let models_manager = thread_manager.get_models_manager(); wait_for_model_available(&models_manager, model).await; - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let cwd_path = cwd.path().to_path_buf(); let (sandbox_policy, permission_profile) = diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index db64289354..fc7a23215f 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -423,23 +423,14 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu config.model = Some("gpt-5.3-codex".to_string()); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &resumed.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("gpt-5.4".to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; resumed .codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index e128a189b5..05ec967d09 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -789,23 +789,15 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { }) .await; - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(repo_path.to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .unwrap(); + ..Default::default() + }, + ) + .await + .unwrap(); codex .submit(Op::Review { diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index d18aa669ca..464e8187b4 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -70,7 +70,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style + - `Op::UserInput` accepts an optional `personality` turn-context override that updates the model’s communication style Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 1898bebeeb..167d56da5b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -333,6 +333,7 @@ async fn run_codex_tool_session_inner( } EventMsg::AgentReasoningRawContent(_) | EventMsg::TurnStarted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) | EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningSectionBreak(_) diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 4fcb1d409b..30240f04ca 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -242,22 +242,14 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu let test = build_test_codex(&server, home).await?; assert_eq!(test.config.service_tier, None); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast.request_value().to_string())).await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cec929b07f..d5bf200386 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -396,7 +396,8 @@ pub struct ConversationTextParams { pub text: String, } -/// Persistent thread-settings overrides that can be applied before user input. +/// Persistent thread-settings overrides that can be applied before user input or +/// on their own. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. @@ -518,76 +519,22 @@ pub enum Op { thread_settings: ThreadSettingsOverrides, }, + /// Apply persistent thread-settings overrides without starting a turn. + /// + /// This uses the same submission queue as turn starts so app-server can + /// preserve caller order between both kinds of mutation. + ThreadSettings { + /// Persistent thread-settings overrides to apply. + #[serde(flatten)] + thread_settings: ThreadSettingsOverrides, + }, + /// Inter-agent communication that should be recorded as assistant history /// while still using the normal thread submission lifecycle. InterAgentCommunication { communication: InterAgentCommunication, }, - /// Override parts of the persistent thread settings for subsequent turns. - /// - /// All fields are optional; when omitted, the existing value is preserved. - /// This does not enqueue any input – it only updates defaults used for - /// turns that rely on persistent session-level settings (for example, - /// [`Op::UserInput`]). - OverrideTurnContext { - /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - cwd: Option, - - /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] - approval_policy: Option, - - /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] - approvals_reviewer: Option, - - /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - sandbox_policy: Option, - - /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - permission_profile: Option, - - /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] - windows_sandbox_level: Option, - - /// Updated model slug. When set, the model info is derived - /// automatically. - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - - /// Updated reasoning effort (honored only for reasoning-capable models). - /// - /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear - /// the effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - effort: Option>, - - /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - - /// Updated service tier preference for future turns. - /// - /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the - /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, - - /// EXPERIMENTAL - set a pre-set collaboration mode. - /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] - collaboration_mode: Option, - - /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] - personality: Option, - }, - /// Approve a command execution ExecApproval { /// The id of the submission we are approving @@ -775,8 +722,8 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", + Self::ThreadSettings { .. } => "thread_settings", Self::InterAgentCommunication { .. } => "inter_agent_communication", - Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", Self::PatchApproval { .. } => "patch_approval", Self::ResolveElicitation { .. } => "resolve_elicitation", @@ -1227,6 +1174,10 @@ pub enum EventMsg { #[serde(rename = "task_started", alias = "turn_started")] TurnStarted(TurnStartedEvent), + /// Persistent thread-settings overrides from the correlated submission have + /// been applied to the session configuration. + ThreadSettingsApplied(ThreadSettingsAppliedEvent), + /// Agent has completed all actions. /// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop. #[serde(rename = "task_complete", alias = "turn_complete")] @@ -1907,6 +1858,33 @@ pub struct TurnStartedEvent { pub collaboration_mode_kind: ModeKind, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadSettingsAppliedEvent { + pub thread_settings: ThreadSettingsSnapshot, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadSettingsSnapshot { + pub model: String, + pub model_provider_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub permission_profile: PermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub active_permission_profile: Option, + pub cwd: AbsolutePathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + pub collaboration_mode: CollaborationMode, +} + #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] pub struct TokenUsage { #[ts(type = "number")] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 1e49c82be2..a862af116c 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -228,6 +228,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::ContextCompacted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) | EventMsg::AgentMessage(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index ceb617763f..b6dbba98f8 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -183,6 +183,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::McpToolCallBegin(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) From 7cdeab33d1c269a2180477925bae0e5c5ce2eef5 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 18 May 2026 21:33:05 -0700 Subject: [PATCH 096/114] CI: Customize v8 building (#22086) ## Summary Move the rusty_v8 artifact production into hermetic Bazel path and bump the `v8` crate to `147.4.0` The new flow builds V8 release artifacts from source for Darwin and Linux targets, publishes both the current release-compatible artifacts and sandbox-enabled variants, and keeps Cargo consumers on prebuilt binaries by continuing to feed the `v8` crate the archive and generated binding files it already expects. ## Why We need control over V8 build-time features without giving up prebuilt artifacts for downstream Cargo builds. Upstream `rusty_v8` already supports source-only features such as `v8_enable_sandbox`, but its normal prebuilt release assets do not cover every feature combination we need. Building the artifacts ourselves lets us enable settings such as the V8 sandbox and pointer compression at artifact build time, then publish those outputs so ordinary Cargo builds can still consume prebuilts instead of compiling V8 locally. This keeps the fast consumer experience of prebuilt `rusty_v8` archives while giving us a reproducible path to ship featureful variants that upstream does not currently publish for us. ## Implementation Notes The Bazel graph in this PR is not copied wholesale from `rusty_v8`; `rusty_v8`'s normal source build is still GN/Ninja-based. Instead, this change starts from upstream V8's Bazel rules and adapts them to Codex's hermetic toolchains and dependency layout. Where we intentionally follow `rusty_v8`, we mirror its existing artifact contract: - the same `v8` crate version and generated binding expectations - the same sandbox feature relationship, where sandboxing requires pointer compression - the same custom libc++ model expected by Cargo's default `use_custom_libcxx` feature - the same release-style archive plus `src_binding` outputs consumed by the `v8` crate To preserve that contract, the Bazel release path pins the libc++, libc++abi, and llvm-libc revisions used by `rusty_v8 v147.4.0`, builds release artifacts with `--config=rusty-v8-upstream-libcxx`, and folds the matching runtime objects into the final static archive. ## Windows Windows is annoyingly handled differently. Codex's current hermetic Bazel Windows C++ platform is `windows-gnullvm` / `x86_64-w64-windows-gnu`, while upstream `rusty_v8` publishes Windows prebuilts for `*-pc-windows-msvc`. Those are different ABIs, so the Bazel graph cannot truthfully reproduce the upstream MSVC artifacts until we add a real MSVC-targeting C++ toolchain. For now: - Windows MSVC consumers continue to use upstream `rusty_v8` release archives. - Windows GNU targets are built in-tree so they link against a matching GNU ABI. - The canary workflow separately exercises upstream `rusty_v8` source builds for MSVC sandbox artifacts, but MSVC is not yet part of the Bazel-produced release matrix. ## Validation This PR is technically self validating through CI. I have already published it as a release tag so the artifacts from this branch are published to https://github.com/openai/codex/releases/tag/rusty-v8-v147.4.0 CI for this PR should therefore consume our own release targets. I have also locally tested for linux and darwin. --------- Co-authored-by: Codex --- .bazelrc | 5 + .codex/skills/update-v8-version/SKILL.md | 72 +++ .../update-v8-version/agents/openai.yaml | 4 + .../actions/setup-rusty-v8-musl/action.yml | 6 +- .github/scripts/rusty_v8_bazel.py | 231 +++++----- .github/scripts/rusty_v8_module_bazel.py | 13 + .github/scripts/test_rusty_v8_bazel.py | 287 ++++++++++++ .github/workflows/rusty-v8-release.yml | 182 +++++++- .github/workflows/v8-canary.yml | 303 ++++++++++++- MODULE.bazel | 128 ++---- MODULE.bazel.lock | 49 +- codex-rs/Cargo.lock | 148 +++--- codex-rs/Cargo.toml | 2 +- codex-rs/v8-poc/src/lib.rs | 11 + patches/BUILD.bazel | 2 + patches/llvm_rusty_v8_custom_libcxx.patch | 79 ++++ patches/rules_cc_rusty_v8_custom_libcxx.patch | 44 ++ patches/v8_bazel_rules.patch | 195 ++++++-- third_party/v8/BUILD.bazel | 424 ++++++++++++++---- third_party/v8/README.md | 66 ++- third_party/v8/libcxx.BUILD.bazel | 157 +++++++ third_party/v8/libcxx_config/BUILD.bazel | 12 + .../v8/libcxx_config/__assertion_handler | 27 ++ third_party/v8/libcxx_config/__config_site | 55 +++ third_party/v8/libcxxabi.BUILD.bazel | 98 ++++ third_party/v8/llvm_libc.BUILD.bazel | 10 + third_party/v8/rusty_v8_146_4_0.sha256 | 10 - third_party/v8/rusty_v8_147_4_0.sha256 | 2 + third_party/v8/v8_crate.BUILD.bazel | 5 + 29 files changed, 2173 insertions(+), 454 deletions(-) create mode 100644 .codex/skills/update-v8-version/SKILL.md create mode 100644 .codex/skills/update-v8-version/agents/openai.yaml create mode 100644 patches/llvm_rusty_v8_custom_libcxx.patch create mode 100644 patches/rules_cc_rusty_v8_custom_libcxx.patch create mode 100644 third_party/v8/libcxx.BUILD.bazel create mode 100644 third_party/v8/libcxx_config/BUILD.bazel create mode 100644 third_party/v8/libcxx_config/__assertion_handler create mode 100644 third_party/v8/libcxx_config/__config_site create mode 100644 third_party/v8/libcxxabi.BUILD.bazel create mode 100644 third_party/v8/llvm_libc.BUILD.bazel delete mode 100644 third_party/v8/rusty_v8_146_4_0.sha256 create mode 100644 third_party/v8/rusty_v8_147_4_0.sha256 diff --git a/.bazelrc b/.bazelrc index 911e0802b5..6357eb83e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -193,5 +193,10 @@ common --@v8//:v8_enable_sandbox=True common:v8-release-compat --@v8//:v8_enable_pointer_compression=False common:v8-release-compat --@v8//:v8_enable_sandbox=False +# Match rusty_v8's upstream GN release contract for published artifacts: every +# target object uses Chromium's custom libc++ headers and the archive folds in +# the matching runtime objects. +common:rusty-v8-upstream-libcxx --@v8//:v8_use_rusty_v8_custom_libcxx=True + # Optional per-user local overrides. try-import %workspace%/user.bazelrc diff --git a/.codex/skills/update-v8-version/SKILL.md b/.codex/skills/update-v8-version/SKILL.md new file mode 100644 index 0000000000..512cc0d5a7 --- /dev/null +++ b/.codex/skills/update-v8-version/SKILL.md @@ -0,0 +1,72 @@ +--- +name: update-v8-version +description: Update Codex's pinned `v8` / `rusty_v8` versions, validate the release-candidate path, and investigate failed V8 canary or artifact builds. Use when asked to bump V8, update `rusty_v8` artifacts, prepare or validate a V8 release candidate, check `v8-canary`, or diagnose why a V8 version update no longer builds. +--- + +# Update V8 Version + +## Core Workflow + +1. Read `third_party/v8/README.md` and follow its version-bump sequence. Treat + that document as the release-process source of truth. +2. Inspect and update the concrete repo surfaces that carry the pin: + - `codex-rs/Cargo.toml` + - `codex-rs/Cargo.lock` + - `MODULE.bazel` + - `third_party/v8/BUILD.bazel` + - `third_party/v8/README.md` + - the matching `third_party/v8/rusty_v8_.sha256` manifest when the + remaining prebuilt inputs change +3. Keep the existing checksum helpers in the loop: + + ```bash + python3 .github/scripts/rusty_v8_bazel.py update-module-bazel + python3 .github/scripts/rusty_v8_bazel.py check-module-bazel + python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + ``` + +4. Validate the release-candidate path before broadening the work: + - Prefer checking the `v8-canary` CI result for the candidate branch or PR + when one exists, using GitHub check tooling or `gh` as appropriate. + - If CI is unavailable or the user asked for a local-only check, run the + closest local validation that is practical for the changed surface and say + explicitly that it is a local substitute, not the full hosted canary. +5. If the canary path passes, stop there. Summarize the result and encourage the + user to commit the candidate changes or proceed with the release flow they + requested. Do not publish tags, releases, or pushes unless the user asked. + +## Failure Path + +Enter this path only when the canary or local build path fails. + +1. Capture the failing target, workflow job, and first actionable error. +2. Compare the currently pinned version with the target version at the relevant + upstream tag or SHA. Inspect both: + - `denoland/rusty_v8` + - upstream V8 source at the target Bazel-pinned version +3. Track build-relevant deltas rather than broad source churn: + - generated binding layout changes + - archive or asset naming changes + - GN/Bazel target changes + - custom libc++ / libc++abi / llvm-libc inputs + - sandbox or pointer-compression feature relationships + - patch hunks in `patches/` that no longer apply or no longer match upstream +4. Trace each failing delta back into Codex's build graph: + - `MODULE.bazel` + - `third_party/v8/BUILD.bazel` + - `.github/scripts/rusty_v8_bazel.py` + - `.github/workflows/v8-canary.yml` + - `.github/workflows/rusty-v8-release.yml` +5. Update only the pieces required to restore the target version's build and + artifact contract. Keep patch explanations and doc changes close to the + affected files. +6. Re-run the focused validation. If it becomes green, return to the normal + workflow and stop with a concise summary plus the remaining release step. + +## Reporting + +- Say whether validation came from hosted `v8-canary` or from a local + substitute. +- Distinguish "version bump complete" from "release published". +- When blocked, report the upstream delta that matters, the Codex file it hits, + and the next concrete fix to try. diff --git a/.codex/skills/update-v8-version/agents/openai.yaml b/.codex/skills/update-v8-version/agents/openai.yaml new file mode 100644 index 0000000000..36e7af80d8 --- /dev/null +++ b/.codex/skills/update-v8-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Update V8 Version" + short_description: "Guide V8 bumps and release validation" + default_prompt: "Use $update-v8-version to update Codex to a new v8 release and validate the release-candidate path." diff --git a/.github/actions/setup-rusty-v8-musl/action.yml b/.github/actions/setup-rusty-v8-musl/action.yml index 871c73a268..fbec1feb46 100644 --- a/.github/actions/setup-rusty-v8-musl/action.yml +++ b/.github/actions/setup-rusty-v8-musl/action.yml @@ -31,16 +31,14 @@ runs: archive_path="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" - checksums_source="${GITHUB_WORKSPACE}/third_party/v8/rusty_v8_${version//./_}.sha256" mkdir -p "${binding_dir}" curl -fsSL "${base_url}/librusty_v8_release_${TARGET}.a.gz" -o "${archive_path}" curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" - grep -E " (librusty_v8_release_${TARGET}[.]a[.]gz|src_binding_release_${TARGET}[.]rs)$" \ - "${checksums_source}" > "${checksums_path}" + curl -fsSL "${base_url}/rusty_v8_release_${TARGET}.sha256" -o "${checksums_path}" if [[ "$(wc -l < "${checksums_path}")" -ne 2 ]]; then - echo "Expected exactly two checksums for ${TARGET} in ${checksums_source}" >&2 + echo "Expected exactly two checksums for ${TARGET} in ${checksums_path}" >&2 exit 1 fi diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 5ad6d3c408..2f46daf45e 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,17 +5,18 @@ from __future__ import annotations import argparse import gzip import hashlib +import os import re import shutil import subprocess import sys -import tempfile import tomllib from pathlib import Path from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, + rusty_v8_http_file_versions, update_module_bazel, ) @@ -23,12 +24,16 @@ from rusty_v8_module_bazel import ( ROOT = Path(__file__).resolve().parents[2] MODULE_BAZEL = ROOT / "MODULE.bazel" RUSTY_V8_CHECKSUMS_DIR = ROOT / "third_party" / "v8" -MUSL_RUNTIME_ARCHIVE_LABELS = [ - "@llvm//runtimes/libcxx:libcxx.static", - "@llvm//runtimes/libcxx:libcxxabi.static", -] -LLVM_AR_LABEL = "@llvm//tools:llvm-ar" -LLVM_RANLIB_LABEL = "@llvm//tools:llvm-ranlib" +RELEASE_ARTIFACT_PROFILE = "release" +SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" +ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] + + +def bazel_remote_args() -> list[str]: + buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") + if not buildbuddy_api_key: + return [] + return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] def bazel_execroot() -> Path: @@ -75,6 +80,7 @@ def bazel_output_files( compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), "--output=files", expression, ], @@ -91,8 +97,10 @@ def bazel_build( labels: list[str], compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, + download_toplevel: bool = False, ) -> None: bazel_configs = bazel_configs or [] + download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( [ "bazel", @@ -101,6 +109,8 @@ def bazel_build( compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), + *download_args, *labels, ], cwd=ROOT, @@ -114,11 +124,15 @@ def ensure_bazel_output_files( compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, ) -> list[Path]: - outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) - if all(path.exists() for path in outputs): - return outputs - - bazel_build(platform, labels, compilation_mode, bazel_configs) + # Bazel output paths can be reused across config flips, so existence alone + # does not prove the files match the requested flags. + bazel_build( + platform, + labels, + compilation_mode, + bazel_configs, + download_toplevel=True, + ) outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) missing = [str(path) for path in outputs if not path.exists()] if missing: @@ -126,9 +140,18 @@ def ensure_bazel_output_files( return outputs -def release_pair_label(target: str) -> str: +def artifact_bazel_configs(bazel_configs: list[str] | None = None) -> list[str]: + configured = list(ARTIFACT_BAZEL_CONFIGS) + for config in bazel_configs or []: + if config not in configured: + configured.append(config) + return configured + + +def release_pair_label(target: str, sandbox: bool = False) -> str: target_suffix = target.replace("-", "_") - return f"//third_party/v8:rusty_v8_release_pair_{target_suffix}" + pair_kind = "sandbox_release_pair" if sandbox else "release_pair" + return f"//third_party/v8:rusty_v8_{pair_kind}_{target_suffix}" def resolved_v8_crate_version() -> str: @@ -169,6 +192,16 @@ def rusty_v8_checksum_manifest_path(version: str) -> Path: def command_version(version: str | None) -> str: if version is not None: return version + + manifest_versions = rusty_v8_http_file_versions(MODULE_BAZEL.read_text()) + if len(manifest_versions) == 1: + return manifest_versions[0] + if len(manifest_versions) > 1: + raise SystemExit( + "expected at most one rusty_v8 http_file version in MODULE.bazel, " + f"found: {manifest_versions}; pass --version explicitly" + ) + return resolved_v8_crate_version() @@ -180,66 +213,76 @@ def command_manifest_path(manifest: Path | None, version: str) -> Path: return ROOT / manifest -def staged_archive_name(target: str, source_path: Path) -> str: - if source_path.suffix == ".lib": - return f"rusty_v8_release_{target}.lib.gz" - return f"librusty_v8_release_{target}.a.gz" +def staged_archive_name(target: str, source_path: Path, artifact_profile: str) -> str: + if target.endswith("-pc-windows-msvc"): + return f"rusty_v8_{artifact_profile}_{target}.lib.gz" + return f"librusty_v8_{artifact_profile}_{target}.a.gz" -def is_musl_archive_target(target: str, source_path: Path) -> bool: - return target.endswith("-unknown-linux-musl") and source_path.suffix == ".a" +def staged_binding_name(target: str, artifact_profile: str) -> str: + return f"src_binding_{artifact_profile}_{target}.rs" -def single_bazel_output_file( - platform: str, - label: str, - compilation_mode: str = "fastbuild", - bazel_configs: list[str] | None = None, -) -> Path: - outputs = ensure_bazel_output_files(platform, [label], compilation_mode, bazel_configs) - if len(outputs) != 1: - raise SystemExit(f"expected exactly one output for {label}, found {outputs}") - return outputs[0] +def staged_checksums_name(target: str, artifact_profile: str) -> str: + return f"rusty_v8_{artifact_profile}_{target}.sha256" -def merged_musl_archive( - platform: str, +def stage_artifacts( + target: str, lib_path: Path, - compilation_mode: str = "fastbuild", - bazel_configs: list[str] | None = None, -) -> Path: - llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode, bazel_configs) - llvm_ranlib = single_bazel_output_file( - platform, - LLVM_RANLIB_LABEL, - compilation_mode, - bazel_configs, - ) - runtime_archives = [ - single_bazel_output_file(platform, label, compilation_mode, bazel_configs) - for label in MUSL_RUNTIME_ARCHIVE_LABELS - ] + binding_path: Path, + output_dir: Path, + sandbox: bool, +) -> None: + missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + if missing_paths: + raise SystemExit(f"missing release outputs for {target}: {missing_paths}") - temp_dir = Path(tempfile.mkdtemp(prefix="rusty-v8-musl-stage-")) - merged_archive = temp_dir / lib_path.name - merge_commands = "\n".join( - [ - f"create {merged_archive}", - f"addlib {lib_path}", - *[f"addlib {archive}" for archive in runtime_archives], - "save", - "end", - ] - ) - subprocess.run( - [str(llvm_ar), "-M"], - cwd=ROOT, - check=True, - input=merge_commands, - text=True, - ) - subprocess.run([str(llvm_ranlib), str(merged_archive)], cwd=ROOT, check=True) - return merged_archive + output_dir.mkdir(parents=True, exist_ok=True) + artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE + staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_binding = output_dir / staged_binding_name(target, artifact_profile) + + with lib_path.open("rb") as src, staged_library.open("wb") as dst: + with gzip.GzipFile( + filename="", + mode="wb", + fileobj=dst, + compresslevel=6, + mtime=0, + ) as gz: + shutil.copyfileobj(src, gz) + + shutil.copyfile(binding_path, staged_binding) + + staged_checksums = output_dir / staged_checksums_name(target, artifact_profile) + with staged_checksums.open("w", encoding="utf-8") as checksums: + for path in [staged_library, staged_binding]: + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + checksums.write(f"{digest.hexdigest()} {path.name}\n") + + print(staged_library) + print(staged_binding) + print(staged_checksums) + + +def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: + lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + gn_out = source_root / "target" / target / "release" / "gn_out" + return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" + + +def stage_upstream_release_pair( + source_root: Path, + target: str, + output_dir: Path, + sandbox: bool = False, +) -> None: + lib_path, binding_path = upstream_release_pair_paths(source_root, target) + stage_artifacts(target, lib_path, binding_path, output_dir, sandbox) def stage_release_pair( @@ -248,10 +291,12 @@ def stage_release_pair( output_dir: Path, compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, + sandbox: bool = False, ) -> None: + bazel_configs = artifact_bazel_configs(bazel_configs) outputs = ensure_bazel_output_files( platform, - [release_pair_label(target)], + [release_pair_label(target, sandbox)], compilation_mode, bazel_configs, ) @@ -266,39 +311,7 @@ def stage_release_pair( except StopIteration as exc: raise SystemExit(f"missing Rust binding output for {target}") from exc - output_dir.mkdir(parents=True, exist_ok=True) - staged_library = output_dir / staged_archive_name(target, lib_path) - staged_binding = output_dir / f"src_binding_release_{target}.rs" - source_archive = ( - merged_musl_archive(platform, lib_path, compilation_mode, bazel_configs) - if is_musl_archive_target(target, lib_path) - else lib_path - ) - - with source_archive.open("rb") as src, staged_library.open("wb") as dst: - with gzip.GzipFile( - filename="", - mode="wb", - fileobj=dst, - compresslevel=6, - mtime=0, - ) as gz: - shutil.copyfileobj(src, gz) - - shutil.copyfile(binding_path, staged_binding) - - staged_checksums = output_dir / f"rusty_v8_release_{target}.sha256" - with staged_checksums.open("w", encoding="utf-8") as checksums: - for path in [staged_library, staged_binding]: - digest = hashlib.sha256() - with path.open("rb") as artifact: - for chunk in iter(lambda: artifact.read(1024 * 1024), b""): - digest.update(chunk) - checksums.write(f"{digest.hexdigest()} {path.name}\n") - - print(staged_library) - print(staged_binding) - print(staged_checksums) + stage_artifacts(target, lib_path, binding_path, output_dir, sandbox) def parse_args() -> argparse.Namespace: @@ -309,6 +322,7 @@ def parse_args() -> argparse.Namespace: stage_release_pair_parser.add_argument("--platform", required=True) stage_release_pair_parser.add_argument("--target", required=True) stage_release_pair_parser.add_argument("--output-dir", required=True) + stage_release_pair_parser.add_argument("--sandbox", action="store_true") stage_release_pair_parser.add_argument( "--bazel-config", action="append", @@ -321,6 +335,14 @@ def parse_args() -> argparse.Namespace: choices=["fastbuild", "opt", "dbg"], ) + stage_upstream_release_pair_parser = subparsers.add_parser( + "stage-upstream-release-pair" + ) + stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument("--target", required=True) + stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) + stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") + subparsers.add_parser("resolved-v8-crate-version") check_module_bazel_parser = subparsers.add_parser("check-module-bazel") @@ -353,6 +375,15 @@ def main() -> int: output_dir=Path(args.output_dir), compilation_mode=args.compilation_mode, bazel_configs=args.bazel_configs, + sandbox=args.sandbox, + ) + return 0 + if args.command == "stage-upstream-release-pair": + stage_upstream_release_pair( + source_root=args.source_root, + target=args.target, + output_dir=Path(args.output_dir), + sandbox=args.sandbox, ) return 0 if args.command == "resolved-v8-crate-version": diff --git a/.github/scripts/rusty_v8_module_bazel.py b/.github/scripts/rusty_v8_module_bazel.py index 7f474fc5d2..5d5dec08d5 100644 --- a/.github/scripts/rusty_v8_module_bazel.py +++ b/.github/scripts/rusty_v8_module_bazel.py @@ -9,6 +9,7 @@ from pathlib import Path SHA256_RE = re.compile(r"[0-9a-f]{64}") HTTP_FILE_BLOCK_RE = re.compile(r"(?ms)^http_file\(\n.*?^\)\n?") +HTTP_FILE_VERSION_RE = re.compile(r"^rusty_v8_([0-9]+)_([0-9]+)_([0-9]+)_") class RustyV8ChecksumError(ValueError): @@ -95,6 +96,18 @@ def rusty_v8_http_files(module_bazel: str, version: str) -> list[RustyV8HttpFile return entries +def rusty_v8_http_file_versions(module_bazel: str) -> list[str]: + versions = set() + for match in HTTP_FILE_BLOCK_RE.finditer(module_bazel): + name = string_field(match.group(0), "name") + if not name: + continue + version_match = HTTP_FILE_VERSION_RE.match(name) + if version_match: + versions.add(".".join(version_match.groups())) + return sorted(versions) + + def module_entry_set_errors( entries: list[RustyV8HttpFile], checksums: dict[str, str], diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index e86e82e8b2..19690dbece 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -4,11 +4,270 @@ from __future__ import annotations import textwrap import unittest +from os import environ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +import rusty_v8_bazel import rusty_v8_module_bazel class RustyV8BazelTest(unittest.TestCase): + def test_consumer_selectors_track_resolved_crate_version(self) -> None: + build_bazel = ( + rusty_v8_bazel.ROOT / "third_party" / "v8" / "BUILD.bazel" + ).read_text() + version_suffix = rusty_v8_bazel.resolved_v8_crate_version().replace(".", "_") + + for selector in [ + "aarch64_apple_darwin_bazel", + "aarch64_pc_windows_gnullvm", + "aarch64_pc_windows_msvc", + "aarch64_unknown_linux_gnu_bazel", + "aarch64_unknown_linux_musl_release_base", + "x86_64_apple_darwin_bazel", + "x86_64_pc_windows_gnullvm", + "x86_64_pc_windows_msvc", + "x86_64_unknown_linux_gnu_bazel", + "x86_64_unknown_linux_musl_release", + ]: + self.assertIn( + f":v8_{version_suffix}_{selector}", + build_bazel, + ) + + for selector in [ + "aarch64_apple_darwin", + "aarch64_pc_windows_gnullvm", + "aarch64_pc_windows_msvc", + "aarch64_unknown_linux_gnu", + "aarch64_unknown_linux_musl", + "x86_64_apple_darwin", + "x86_64_pc_windows_gnullvm", + "x86_64_pc_windows_msvc", + "x86_64_unknown_linux_gnu", + "x86_64_unknown_linux_musl", + ]: + self.assertIn( + f":src_binding_release_{selector}_{version_suffix}_release", + build_bazel, + ) + + def test_command_version_tracks_remaining_http_file_assets(self) -> None: + with TemporaryDirectory() as temp_dir: + module_bazel = Path(temp_dir) / "MODULE.bazel" + module_bazel.write_text( + textwrap.dedent( + """\ + http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", + urls = ["https://example.test/archive.gz"], + ) + """ + ) + ) + + with patch.object(rusty_v8_bazel, "MODULE_BAZEL", module_bazel): + self.assertEqual("146.4.0", rusty_v8_bazel.command_version(None)) + + def test_artifact_bazel_configs_always_enable_upstream_libcxx(self) -> None: + self.assertEqual( + ["rusty-v8-upstream-libcxx"], + rusty_v8_bazel.artifact_bazel_configs(), + ) + self.assertEqual( + ["rusty-v8-upstream-libcxx", "v8-release-compat"], + rusty_v8_bazel.artifact_bazel_configs(["v8-release-compat"]), + ) + self.assertEqual( + ["rusty-v8-upstream-libcxx", "v8-release-compat"], + rusty_v8_bazel.artifact_bazel_configs( + ["rusty-v8-upstream-libcxx", "v8-release-compat"] + ), + ) + + def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + self.assertEqual( + ["--remote_header=x-buildbuddy-api-key=token"], + rusty_v8_bazel.bazel_remote_args(), + ) + + with patch.dict(environ, {}, clear=True): + self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) + + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + self.assertEqual( + "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), + ) + self.assertEqual( + "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + ) + self.assertEqual( + "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", + rusty_v8_bazel.release_pair_label("x86_64-apple-darwin", sandbox=True), + ) + self.assertEqual( + "librusty_v8_release_x86_64-unknown-linux-musl.a.gz", + rusty_v8_bazel.staged_archive_name( + "x86_64-unknown-linux-musl", + Path("libv8.a"), + rusty_v8_bazel.RELEASE_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.lib.gz", + rusty_v8_bazel.staged_archive_name( + "x86_64-pc-windows-msvc", + Path("v8.a"), + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "src_binding_ptrcomp_sandbox_release_x86_64-unknown-linux-musl.rs", + rusty_v8_bazel.staged_binding_name( + "x86_64-unknown-linux-musl", + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "rusty_v8_ptrcomp_sandbox_release_x86_64-unknown-linux-musl.sha256", + rusty_v8_bazel.staged_checksums_name( + "x86_64-unknown-linux-musl", + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + + def test_stage_artifacts(self) -> None: + with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: + source_root = Path(source_dir) + archive = source_root / "librusty_v8.a" + binding = source_root / "src_binding.rs" + archive.write_bytes(b"archive") + binding.write_text("binding") + + rusty_v8_bazel.stage_artifacts( + "aarch64-apple-darwin", + archive, + binding, + Path(output_dir), + sandbox=True, + ) + + self.assertEqual( + { + "librusty_v8_ptrcomp_sandbox_release_aarch64-apple-darwin.a.gz", + "src_binding_ptrcomp_sandbox_release_aarch64-apple-darwin.rs", + "rusty_v8_ptrcomp_sandbox_release_aarch64-apple-darwin.sha256", + }, + {path.name for path in Path(output_dir).iterdir()}, + ) + + def test_upstream_release_pair_paths(self) -> None: + self.assertEqual( + ( + Path( + "/tmp/rusty_v8/target/x86_64-apple-darwin/release/gn_out/obj/" + "librusty_v8.a" + ), + Path( + "/tmp/rusty_v8/target/x86_64-apple-darwin/release/gn_out/" + "src_binding.rs" + ), + ), + rusty_v8_bazel.upstream_release_pair_paths( + Path("/tmp/rusty_v8"), + "x86_64-apple-darwin", + ), + ) + self.assertEqual( + ( + Path( + "/tmp/rusty_v8/target/x86_64-pc-windows-msvc/release/gn_out/" + "obj/rusty_v8.lib" + ), + Path( + "/tmp/rusty_v8/target/x86_64-pc-windows-msvc/release/gn_out/" + "src_binding.rs" + ), + ), + rusty_v8_bazel.upstream_release_pair_paths( + Path("/tmp/rusty_v8"), + "x86_64-pc-windows-msvc", + ), + ) + + def test_stage_upstream_release_pair(self) -> None: + with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: + source_root = Path(source_dir) + gn_out = ( + source_root + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "gn_out" + ) + (gn_out / "obj").mkdir(parents=True) + (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") + (gn_out / "src_binding.rs").write_text("binding") + + rusty_v8_bazel.stage_upstream_release_pair( + source_root, + "x86_64-pc-windows-msvc", + Path(output_dir), + sandbox=True, + ) + + self.assertEqual( + { + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.lib.gz", + "src_binding_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.rs", + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.sha256", + }, + {path.name for path in Path(output_dir).iterdir()}, + ) + + def test_ensure_bazel_output_files_rebuilds_existing_outputs(self) -> None: + with TemporaryDirectory() as output_dir: + output = Path(output_dir) / "libv8.a" + output.write_bytes(b"archive") + + with ( + patch.object(rusty_v8_bazel, "bazel_build") as bazel_build, + patch.object( + rusty_v8_bazel, + "bazel_output_files", + return_value=[output], + ) as bazel_output_files, + ): + self.assertEqual( + [output], + rusty_v8_bazel.ensure_bazel_output_files( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + ), + ) + + bazel_build.assert_called_once_with( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + download_toplevel=True, + ) + bazel_output_files.assert_called_once_with( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + ) + def test_update_module_bazel_replaces_and_inserts_sha256(self) -> None: module_bazel = textwrap.dedent( """\ @@ -121,6 +380,34 @@ class RustyV8BazelTest(unittest.TestCase): "146.4.0", ) + def test_rusty_v8_http_file_versions(self) -> None: + module_bazel = textwrap.dedent( + """\ + http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "archive.gz", + urls = ["https://example.test/archive.gz"], + ) + + http_file( + name = "rusty_v8_147_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "new-archive.gz", + urls = ["https://example.test/new-archive.gz"], + ) + + http_file( + name = "unrelated_archive", + downloaded_file_path = "other.gz", + urls = ["https://example.test/other.gz"], + ) + """ + ) + + self.assertEqual( + ["146.4.0", "147.4.0"], + rusty_v8_module_bazel.rusty_v8_http_file_versions(module_bazel), + ) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 5a436722bb..3b56a1e93f 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -46,14 +46,14 @@ jobs: expected_release_tag="rusty-v8-v${V8_VERSION}" release_tag="${GITHUB_REF_NAME}" if [[ "${release_tag}" != "${expected_release_tag}" ]]; then - echo "Tag ${release_tag} does not match resolved v8 crate version ${V8_VERSION}." >&2 + echo "Tag ${release_tag} does not match expected release tag ${expected_release_tag}." >&2 exit 1 fi echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" build: - name: Build ${{ matrix.target }} + name: Build ${{ matrix.variant }} ${{ matrix.target }} needs: metadata runs-on: ${{ matrix.runner }} permissions: @@ -64,11 +64,77 @@ jobs: matrix: include: - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: false + target: x86_64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: true + target: x86_64-unknown-linux-gnu + variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: false + target: aarch64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: true + target: aarch64-unknown-linux-gnu + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: false + target: x86_64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: true + target: x86_64-apple-darwin + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: false + target: aarch64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: true + target: aarch64-apple-darwin + variant: ptrcomp-sandbox + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: false + target: x86_64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 platform: linux_arm64_musl + sandbox: false target: aarch64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: true + target: x86_64-unknown-linux-musl + variant: ptrcomp-sandbox + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64_musl + sandbox: true + target: aarch64-unknown-linux-musl + variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -85,61 +151,115 @@ jobs: with: python-version: "3.12" + - name: Set up Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + toolchain: "1.93.0" + - name: Build Bazel V8 release pair env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=() - if [[ "${TARGET}" == *-unknown-linux-musl ]]; then - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) + pair_kind="release_pair" + if [[ "${SANDBOX}" == "true" ]]; then + pair_kind="sandbox_release_pair" fi + pair_target="//third_party/v8:rusty_v8_${pair_kind}_${target_suffix}" bazel_args=( build -c opt "--platforms=@llvm//platforms:${PLATFORM}" - --config=v8-release-compat + --config=rusty-v8-upstream-libcxx "${pair_target}" - "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) + if [[ "${SANDBOX}" != "true" ]]; then + bazel_args+=(--config=v8-release-compat) + fi bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - --config=ci-v8 \ + "--config=${{ matrix.bazel_config }}" \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: + BAZEL_CONFIG: ${{ matrix.bazel_config }} + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --compilation-mode opt \ - --bazel-config v8-release-compat \ + stage_args=( + --platform "${PLATFORM}" + --target "${TARGET}" + --compilation-mode opt --output-dir "dist/${TARGET}" + --bazel-config "${BAZEL_CONFIG}" + ) + if [[ "${SANDBOX}" == "true" ]]; then + stage_args+=(--sandbox) + else + stage_args+=(--bazel-config v8-release-compat) + fi - - name: Upload staged musl artifacts + python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" + + - name: Smoke test staged artifact with Cargo + env: + SANDBOX: ${{ matrix.sandbox }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + host_arch="$(uname -m)" + case "${TARGET}:${host_arch}" in + x86_64-apple-darwin:x86_64|aarch64-apple-darwin:arm64|x86_64-unknown-linux-gnu:x86_64|aarch64-unknown-linux-gnu:aarch64) + ;; + *) + echo "Skipping non-native Cargo smoke for ${TARGET} on ${host_arch}." + exit 0 + ;; + esac + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'librusty_v8_*.a.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + cargo_args=(test -p codex-v8-poc) + if [[ "${SANDBOX}" == "true" ]]; then + cargo_args+=(--features sandbox) + fi + + ( + cd codex-rs + CARGO_TARGET_DIR="${RUNNER_TEMP}/rusty-v8-cargo-smoke-${TARGET}-${SANDBOX}" \ + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo "${cargo_args[@]}" + ) + + - name: Upload staged artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} + name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.variant }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* publish-release: @@ -152,7 +272,8 @@ jobs: actions: read steps: - - name: Ensure release tag is new + - name: Check whether release already exists + id: release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }} @@ -161,8 +282,9 @@ jobs: set -euo pipefail if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then - echo "Release tag ${RELEASE_TAG} already exists; musl artifact tags are immutable." >&2 - exit 1 + echo "exists=true" >> "${GITHUB_OUTPUT}" + else + echo "exists=false" >> "${GITHUB_OUTPUT}" fi - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -170,6 +292,7 @@ jobs: path: dist - name: Create GitHub Release + if: ${{ steps.release.outputs.exists != 'true' }} uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ needs.metadata.outputs.release_tag }} @@ -177,3 +300,14 @@ jobs: files: dist/** # Keep V8 artifact releases out of Codex's normal "latest release" channel. prerelease: true + + - name: Amend existing GitHub Release + if: ${{ steps.release.outputs.exists == 'true' }} + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + tag_name: ${{ needs.metadata.outputs.release_tag }} + name: ${{ needs.metadata.outputs.release_tag }} + files: dist/** + overwrite_files: true + # Keep V8 artifact releases out of Codex's normal "latest release" channel. + prerelease: true diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 6e71367d1f..979e991504 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -3,28 +3,36 @@ name: v8-canary on: pull_request: paths: + - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" + - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" - "MODULE.bazel" - "MODULE.bazel.lock" - "codex-rs/Cargo.toml" - "patches/BUILD.bazel" + - "patches/llvm_*.patch" + - "patches/rules_cc_*.patch" - "patches/v8_*.patch" - "third_party/v8/**" push: branches: - main paths: + - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" + - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" - "MODULE.bazel" - "MODULE.bazel.lock" - "codex-rs/Cargo.toml" - "patches/BUILD.bazel" + - "patches/llvm_*.patch" + - "patches/rules_cc_*.patch" - "patches/v8_*.patch" - "third_party/v8/**" workflow_dispatch: @@ -59,7 +67,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" build: - name: Build ${{ matrix.target }} + name: Build ${{ matrix.variant }} ${{ matrix.target }} needs: metadata runs-on: ${{ matrix.runner }} permissions: @@ -70,12 +78,77 @@ jobs: matrix: include: - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: false + target: x86_64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: true + target: x86_64-unknown-linux-gnu + variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: false + target: aarch64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: true + target: aarch64-unknown-linux-gnu + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: false + target: x86_64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: true + target: x86_64-apple-darwin + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: false + target: aarch64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: true + target: aarch64-apple-darwin + variant: ptrcomp-sandbox + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: false + target: x86_64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: true + target: x86_64-unknown-linux-musl + variant: ptrcomp-sandbox + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 platform: linux_arm64_musl + sandbox: false target: aarch64-unknown-linux-musl - + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64_musl + sandbox: true + target: aarch64-unknown-linux-musl + variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -92,53 +165,247 @@ jobs: with: python-version: "3.12" + - name: Set up Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + toolchain: "1.93.0" + - name: Build Bazel V8 release pair env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) + pair_kind="release_pair" + if [[ "${SANDBOX}" == "true" ]]; then + pair_kind="sandbox_release_pair" + fi + pair_target="//third_party/v8:rusty_v8_${pair_kind}_${target_suffix}" bazel_args=( build "--platforms=@llvm//platforms:${PLATFORM}" - --config=v8-release-compat + --config=rusty-v8-upstream-libcxx "${pair_target}" - "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) + if [[ "${SANDBOX}" != "true" ]]; then + bazel_args+=(--config=v8-release-compat) + fi bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - --config=ci-v8 \ + "--config=${{ matrix.bazel_config }}" \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: + BAZEL_CONFIG: ${{ matrix.bazel_config }} + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --bazel-config v8-release-compat \ + stage_args=( + --platform "${PLATFORM}" + --target "${TARGET}" --output-dir "dist/${TARGET}" + --bazel-config "${BAZEL_CONFIG}" + ) + if [[ "${SANDBOX}" == "true" ]]; then + stage_args+=(--sandbox) + else + stage_args+=(--bazel-config v8-release-compat) + fi - - name: Upload staged musl artifacts + python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" + + - name: Smoke test staged artifact with Cargo + env: + SANDBOX: ${{ matrix.sandbox }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + host_arch="$(uname -m)" + case "${TARGET}:${host_arch}" in + x86_64-apple-darwin:x86_64|aarch64-apple-darwin:arm64|x86_64-unknown-linux-gnu:x86_64|aarch64-unknown-linux-gnu:aarch64) + ;; + *) + echo "Skipping non-native Cargo smoke for ${TARGET} on ${host_arch}." + exit 0 + ;; + esac + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'librusty_v8_*.a.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + cargo_args=(test -p codex-v8-poc) + if [[ "${SANDBOX}" == "true" ]]; then + cargo_args+=(--features sandbox) + fi + + ( + cd codex-rs + CARGO_TARGET_DIR="${RUNNER_TEMP}/rusty-v8-cargo-smoke-${TARGET}-${SANDBOX}" \ + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo "${cargo_args[@]}" + ) + + - name: Upload staged artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} + name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.variant }}-${{ matrix.target }} + path: dist/${{ matrix.target }}/* + + build-windows-source: + name: Build ptrcomp-sandbox ${{ matrix.target }} from source + needs: metadata + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + target: x86_64-pc-windows-msvc + - runner: windows-2022 + target: aarch64-pc-windows-msvc + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Configure git for upstream checkout + shell: bash + run: git config --global core.symlinks true + + - name: Check out upstream rusty_v8 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: denoland/rusty_v8 + ref: v${{ needs.metadata.outputs.v8_version }} + path: upstream-rusty-v8 + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + architecture: x64 + + - name: Set up Codex Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + toolchain: "1.93.0" + targets: ${{ matrix.target }} + + - name: Install rusty_v8 Rust toolchain + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.91.0 --profile minimal --no-self-update + rustup target add --toolchain 1.91.0 "${TARGET}" + + - name: Write upstream submodule status + shell: bash + working-directory: upstream-rusty-v8 + run: git submodule status --recursive > git_submodule_status.txt + + - name: Restore upstream source-build cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + upstream-rusty-v8/target/sccache + upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out + key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} + restore-keys: | + rusty-v8-source-${{ matrix.target }}-sandbox- + + - name: Install and start sccache + shell: pwsh + env: + SCCACHE_CACHE_SIZE: 256M + SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache + SCCACHE_IDLE_TIMEOUT: 0 + run: | + $version = "v0.8.2" + $platform = "x86_64-pc-windows-msvc" + $basename = "sccache-$version-$platform" + $url = "https://github.com/mozilla/sccache/releases/download/$version/$basename.tar.gz" + cd ~ + curl -LO $url + tar -xzvf "$basename.tar.gz" + . $basename/sccache --start-server + echo "$(pwd)/$basename" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install Chromium clang for ARM64 MSVC cross build + if: matrix.target == 'aarch64-pc-windows-msvc' + shell: bash + working-directory: upstream-rusty-v8 + run: python3 tools/clang/scripts/update.py + + - name: Build upstream rusty_v8 sandbox release pair + env: + SCCACHE_IDLE_TIMEOUT: 0 + TARGET: ${{ matrix.target }} + V8_FROM_SOURCE: "1" + shell: bash + working-directory: upstream-rusty-v8 + run: cargo +1.91.0 build --locked --release --target "${TARGET}" --features v8_enable_sandbox + + - name: Stage upstream sandbox release pair + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \ + --source-root upstream-rusty-v8 \ + --target "${TARGET}" \ + --output-dir "dist/${TARGET}" \ + --sandbox + + - name: Smoke link staged artifact with Cargo + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'rusty_v8_*.lib.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + ( + cd codex-rs + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo +1.93.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run + ) + + - name: Upload staged artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: v8-canary-${{ needs.metadata.outputs.v8_version }}-ptrcomp-sandbox-${{ matrix.target }} path: dist/${{ matrix.target }}/* diff --git a/MODULE.bazel b/MODULE.bazel index f675038791..81f16b7b9e 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,6 +10,7 @@ single_version_override( module_name = "llvm", patch_strip = 1, patches = [ + "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_symlink_extract.patch", ], ) @@ -77,6 +78,13 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") +single_version_override( + module_name = "rules_cc", + patch_strip = 1, + patches = [ + "//patches:rules_cc_rusty_v8_custom_libcxx.patch", + ], +) bazel_dep(name = "rules_platform", version = "0.1.0") bazel_dep(name = "rules_rs", version = "0.0.58") # `rules_rs` still does not model `windows-gnullvm` as a distinct Windows exec @@ -407,18 +415,18 @@ crate.annotation( inject_repo(crate, "alsa_lib") -bazel_dep(name = "v8", version = "14.6.202.9") +bazel_dep(name = "v8", version = "14.7.173.20") archive_override( module_name = "v8", - integrity = "sha256-JphDwLAzsd9KvgRZ7eQvNtPU6qGd3XjFt/a/1QITAJU=", + integrity = "sha256-v/x6I4X38a2wckzUIft3Dh0SUdkuOTokwxyF7lzW8Lc=", patch_strip = 3, patches = [ "//patches:v8_module_deps.patch", "//patches:v8_bazel_rules.patch", "//patches:v8_source_portability.patch", ], - strip_prefix = "v8-14.6.202.9", - urls = ["https://github.com/v8/v8/archive/refs/tags/14.6.202.9.tar.gz"], + strip_prefix = "v8-14.7.173.20", + urls = ["https://github.com/v8/v8/archive/refs/tags/14.7.173.20.tar.gz"], ) http_archive( @@ -430,93 +438,53 @@ http_archive( urls = ["https://static.crates.io/crates/v8/v8-146.4.0.crate"], ) -http_file( - name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive", - downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz", - sha256 = "bfe2c9be32a56c28546f0f965825ee68fbf606405f310cc4e17b448a568cf98a", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz", - ], +http_archive( + name = "v8_crate_147_4_0", + build_file = "//third_party/v8:v8_crate.BUILD.bazel", + sha256 = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd", + strip_prefix = "v8-147.4.0", + type = "tar.gz", + urls = ["https://static.crates.io/crates/v8/v8-147.4.0.crate"], +) + +git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") + +git_repository( + name = "rusty_v8_libcxx", + build_file = "//third_party/v8:libcxx.BUILD.bazel", + commit = "7ab65651aed6802d2599dcb7a73b1f82d5179d05", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxx.git", +) + +git_repository( + name = "rusty_v8_libcxxabi", + build_file = "//third_party/v8:libcxxabi.BUILD.bazel", + commit = "8f11bb1d4438d0239d0dfc1bd9456a9f31629dda", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxxabi.git", +) + +git_repository( + name = "rusty_v8_llvm_libc", + build_file = "//third_party/v8:llvm_libc.BUILD.bazel", + commit = "b3aa5bb702ff9e890179fd1e7d3ba346e17ecf8e", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libc.git", ) http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive", - downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", - sha256 = "dbf165b07c81bdb054bc046b43d23e69fcf7bcc1a4c1b5b4776983a71062ecd8", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive", + name = "rusty_v8_147_4_0_aarch64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", - sha256 = "ed13363659c6d08583ac8fdc40493445c5767d8b94955a4d5d7bb8d5a81f6bf8", + sha256 = "1fa3f94d9e09cff1f6bcce94c478e5cb072c0755f6a0357abadb9dd3b48d8127", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", + "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", ], ) http_file( - name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive", - downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz", - sha256 = "630cd240f1bbecdb071417dc18387ab81cf67c549c1c515a0b4fcf9eba647bb7", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", - downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", - sha256 = "e64b4d99e4ae293a2e846244a89b80178ba10382c13fb591c1fa6968f5291153", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive", + name = "rusty_v8_147_4_0_x86_64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", - sha256 = "90a9a2346acd3685a355e98df85c24dbe406cb124367d16259a4b5d522621862", + sha256 = "e2827ff98b1a9d4c0343000fc5124ac30dfab3007bc0129c168c9355fc2fcd7c", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive", - downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz", - sha256 = "27a08ed26c34297bfd93e514692ccc44b85f8b15c6aa39cf34e784f84fb37e8e", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding", - downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs", - sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive", - downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz", - sha256 = "20d8271ad712323d352c1383c36e3c4b755abc41ece35819c49c75ec7134d2f8", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding", - downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs", - sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs", + "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", ], ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 87434e1c68..571ab3c33e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -723,7 +723,7 @@ "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "cached_proc_macro_types_0.1.1": "{\"dependencies\":[],\"features\":{}}", - "calendrical_calculations_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", + "calendrical_calculations_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", "camino_1.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.223\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.8\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"proptest1\":[\"dep:proptest\"],\"serde1\":[\"dep:serde_core\"]}}", "cargo-platform_0.3.2": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(any())\"},{\"name\":\"serde_core\",\"req\":\"^1.0.228\"}],\"features\":{}}", "cargo_metadata_0.23.1": "{\"dependencies\":[{\"features\":[\"serde1\"],\"name\":\"camino\",\"req\":\"^1.1.10\"},{\"name\":\"cargo-platform\",\"req\":\"^0.3.0\"},{\"name\":\"derive_builder\",\"optional\":true,\"req\":\"^0.20\"},{\"features\":[\"serde\"],\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"features\":[\"unbounded_depth\"],\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"}],\"features\":{\"builder\":[\"derive_builder\"],\"default\":[],\"unstable\":[]}}", @@ -849,9 +849,9 @@ "difflib_0.4.0": "{\"dependencies\":[],\"features\":{}}", "diffy_0.4.2": "{\"dependencies\":[{\"name\":\"nu-ansi-term\",\"req\":\"^0.50\"}],\"features\":{}}", "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", - "diplomat-runtime_0.14.0": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", - "diplomat_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.14.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", - "diplomat_core_0.14.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", + "diplomat-runtime_0.15.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", + "diplomat_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", + "diplomat_core_0.15.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", "dirs-next_2.0.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"dirs-sys-next\",\"req\":\"^0.1\"}],\"features\":{}}", "dirs-sys-next_0.1.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.4.0\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"knownfolders\",\"objbase\",\"shlobj\",\"winbase\",\"winerror\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "dirs-sys_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"option-ext\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"Win32_UI_Shell\",\"Win32_Foundation\",\"Win32_Globalization\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\">=0.59.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -914,7 +914,7 @@ "find-crate_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^0.11\"},{\"name\":\"toml\",\"req\":\"^0.5.2\"}],\"features\":{}}", "find-msvc-tools_0.1.9": "{\"dependencies\":[],\"features\":{}}", "findshlibs_0.10.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.67\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"libc\",\"req\":\"^0.2.104\"},{\"features\":[\"psapi\",\"memoryapi\",\"libloaderapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{}}", - "fixed_decimal_0.7.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"small\"],\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"experimental\":[],\"ryu\":[\"dep:ryu\"]}}", + "fixed_decimal_0.7.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"small\"],\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"writeable\",\"req\":\"^0.6.1\"}],\"features\":{\"ryu\":[\"dep:ryu\"]}}", "fixedbitset_0.4.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "fixedbitset_0.5.7": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "flate2_1.1.8": "{\"dependencies\":[{\"name\":\"cloudflare-zlib-sys\",\"optional\":true,\"req\":\"^0.3.6\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"libz-ng-sys\",\"optional\":true,\"req\":\"^1.1.16\"},{\"default_features\":false,\"name\":\"libz-sys\",\"optional\":true,\"req\":\"^1.1.20\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"emscripten\\\")))\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"optional\":true,\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\",\"rust-allocator\"],\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5.5\"}],\"features\":{\"any_c_zlib\":[\"any_zlib\"],\"any_impl\":[],\"any_zlib\":[\"any_impl\"],\"cloudflare_zlib\":[\"any_c_zlib\",\"cloudflare-zlib-sys\"],\"default\":[\"rust_backend\"],\"miniz-sys\":[\"rust_backend\"],\"rust_backend\":[\"miniz_oxide\",\"any_impl\"],\"zlib\":[\"any_c_zlib\",\"libz-sys\"],\"zlib-default\":[\"any_c_zlib\",\"libz-sys/default\"],\"zlib-ng\":[\"any_c_zlib\",\"libz-ng-sys\"],\"zlib-ng-compat\":[\"zlib\",\"libz-sys/zlib-ng\"],\"zlib-rs\":[\"any_zlib\",\"dep:zlib-rs\"]}}", @@ -1079,19 +1079,28 @@ "i18n-embed_0.15.4": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fluent\",\"optional\":true,\"req\":\"^0.16\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"gettext\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"i18n-embed-impl\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5\"},{\"name\":\"locale_config\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"maplit\",\"req\":\"^1.0\"},{\"name\":\"notify\",\"optional\":true,\"req\":\"^8.0.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"rust-embed\",\"optional\":true,\"req\":\"^8.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tr\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"name\":\"walkdir\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"Window\",\"Navigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"autoreload\":[\"notify\"],\"default\":[\"rust-embed\"],\"desktop-requester\":[\"locale_config\"],\"filesystem-assets\":[\"walkdir\"],\"fluent-system\":[\"fluent\",\"fluent-syntax\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/fluent-system\",\"arc-swap\"],\"gettext-system\":[\"tr\",\"tr/gettext\",\"dep:gettext\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/gettext-system\"],\"web-sys-requester\":[\"web-sys\"]}}", "iana-time-zone-haiku_0.1.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{}}", "iana-time-zone_0.1.65": "{\"dependencies\":[{\"name\":\"android_system_properties\",\"req\":\"^0.1.5\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"iana-time-zone-haiku\",\"req\":\"^0.1.1\",\"target\":\"cfg(target_os = \\\"haiku\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.66\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.46\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"windows-core\",\"req\":\">=0.56, <=0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"fallback\":[]}}", - "icu_calendar_2.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"icu_calendar_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"ixdtf\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"simple_logger\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"ureq\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"tinystr/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_calendar_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"tinystr/databake\",\"alloc\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"ixdtf\"],\"ixdtf\":[\"dep:ixdtf\"],\"logging\":[\"calendrical_calculations/logging\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"tinystr/serde\",\"icu_provider/serde\"],\"unstable\":[]}}", - "icu_calendar_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", + "icu_calendar_2.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"icu_calendar_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.38.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"ixdtf\",\"optional\":true,\"req\":\"^0.6.5\"},{\"default_features\":false,\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"simple_logger\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"large-dates\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"ureq\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"tinystr/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_calendar_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"tinystr/databake\",\"alloc\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"ixdtf\"],\"ixdtf\":[\"dep:ixdtf\"],\"logging\":[\"calendrical_calculations/logging\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"tinystr/serde\",\"icu_provider/serde\"],\"unstable\":[],\"unstable_chrono_0_4\":[\"dep:chrono\"],\"unstable_jiff_0_2\":[\"dep:jiff\"],\"unstable_time_0_3\":[\"dep:time\"]}}", + "icu_calendar_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_collections_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", - "icu_decimal_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"icu_decimal_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"compiled_data\":[\"dep:icu_decimal_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_provider/export\",\"alloc\"],\"default\":[\"compiled_data\"],\"ryu\":[\"fixed_decimal/ryu\"],\"serde\":[\"dep:serde\",\"icu_provider/serde\",\"zerovec/serde\"]}}", - "icu_decimal_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", - "icu_locale_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"compiled_data\":[\"dep:icu_locale_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_locale_core/databake\",\"tinystr/databake\",\"icu_collections/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"tinystr/serde\",\"zerovec/serde\",\"icu_provider/serde\",\"potential_utf/serde\",\"icu_collections/serde\"]}}", + "icu_collections_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", + "icu_decimal_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.2\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"icu_decimal_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_pattern\",\"optional\":true,\"req\":\"^0.4.2\"},{\"default_features\":false,\"name\":\"icu_plurals\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"compiled_data\":[\"dep:icu_decimal_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\",\"icu_plurals?/compiled_data\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_provider/export\",\"alloc\",\"icu_plurals?/datagen\"],\"default\":[\"compiled_data\"],\"ryu\":[\"fixed_decimal/ryu\"],\"serde\":[\"alloc\",\"dep:serde\",\"icu_provider/serde\",\"zerovec/serde\",\"icu_pattern?/serde\",\"icu_plurals?/serde\"],\"unstable\":[\"dep:icu_pattern\",\"dep:icu_plurals\"]}}", + "icu_decimal_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", + "icu_locale_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_locale_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_locale_core/databake\",\"tinystr/databake\",\"icu_collections/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"tinystr/serde\",\"zerovec/serde\",\"icu_provider/serde\",\"potential_utf/serde\",\"icu_collections/serde\"]}}", "icu_locale_core_2.1.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"litemap/alloc\",\"tinystr/alloc\",\"writeable/alloc\",\"serde?/alloc\"],\"databake\":[\"dep:databake\",\"alloc\"],\"serde\":[\"dep:serde\",\"tinystr/serde\"],\"zerovec\":[\"dep:zerovec\",\"tinystr/zerovec\"]}}", - "icu_locale_data_2.1.2": "{\"dependencies\":[],\"features\":{}}", + "icu_locale_core_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"testing\"],\"kind\":\"dev\",\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.1\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"litemap/alloc\",\"tinystr/alloc\",\"writeable/alloc\",\"serde?/alloc\"],\"databake\":[\"dep:databake\",\"alloc\"],\"serde\":[\"dep:serde\",\"tinystr/serde\"],\"zerovec\":[\"dep:zerovec\",\"tinystr/zerovec\"]}}", + "icu_locale_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_normalizer_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arraystring\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"atoi\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_normalizer_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_properties\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"utf16_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"write16\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"arrayvec\",\"smallvec\"],\"kind\":\"dev\",\"name\":\"write16\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"compiled_data\":[\"dep:icu_normalizer_data\",\"icu_properties?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"icu_properties\",\"icu_collections/databake\",\"zerovec/databake\",\"icu_properties?/datagen\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"utf8_iter\",\"utf16_iter\"],\"experimental\":[],\"icu_properties\":[\"dep:icu_properties\"],\"serde\":[\"dep:serde\",\"icu_collections/serde\",\"zerovec/serde\",\"icu_properties?/serde\",\"icu_provider/serde\"],\"utf16_iter\":[\"dep:utf16_iter\",\"dep:write16\"],\"utf8_iter\":[\"dep:utf8_iter\"],\"write16\":[]}}", + "icu_normalizer_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arraystring\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"atoi\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_normalizer_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"utf16_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"write16\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"arrayvec\",\"smallvec\"],\"kind\":\"dev\",\"name\":\"write16\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_normalizer_data\",\"icu_properties?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"icu_properties\",\"icu_collections/databake\",\"zerovec/databake\",\"icu_properties?/datagen\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"utf8_iter\",\"utf16_iter\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"icu_properties\":[\"dep:icu_properties\"],\"serde\":[\"dep:serde\",\"icu_collections/serde\",\"zerovec/serde\",\"icu_properties?/serde\",\"icu_provider/serde\"],\"utf16_iter\":[\"dep:utf16_iter\",\"dep:write16\"],\"utf8_iter\":[\"dep:utf8_iter\"],\"write16\":[]}}", "icu_normalizer_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", + "icu_normalizer_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", + "icu_plurals_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.2\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_plurals_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_plurals_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"zerovec/databake\",\"dep:databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"icu_normalizer_data\":[],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"icu_provider/serde\",\"dep:displaydoc\"],\"unstable\":[]}}", + "icu_plurals_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_properties_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.1.2\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", + "icu_properties_2.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", "icu_properties_data_2.1.2": "{\"dependencies\":[],\"features\":{}}", + "icu_properties_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_provider_2.1.1": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", + "icu_provider_2.2.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", "id-arena_2.3.0": "{\"dependencies\":[{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ident_case_1.0.1": "{\"dependencies\":[],\"features\":{}}", "idna_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"idna_adapter\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"tester\",\"req\":\"^0.9\"},{\"name\":\"utf8_iter\",\"req\":\"^1.0.4\"}],\"features\":{\"alloc\":[],\"compiled_data\":[\"idna_adapter/compiled_data\"],\"default\":[\"std\",\"compiled_data\"],\"std\":[\"alloc\"]}}", @@ -1413,7 +1422,7 @@ "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", - "resb_0.1.1": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", + "resb_0.1.2": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", @@ -1564,8 +1573,8 @@ "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", - "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", - "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", + "temporal_capi_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"icu_calendar\",\"req\":\"^2.2.1\"},{\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"timezone_provider\",\"req\":\"^0.2.3\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.3.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", + "temporal_rs_0.2.3": "{\"dependencies\":[{\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.2.1\"},{\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"timezone_provider\",\"req\":\"^0.2.3\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys-local\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\"],\"sys-local\":[\"sys\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", "term_0.7.0": "{\"dependencies\":[{\"name\":\"dirs-next\",\"req\":\"^2\"},{\"name\":\"rustversion\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"features\":[\"consoleapi\",\"wincon\",\"handleapi\",\"fileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[]}}", "termcolor_1.4.1": "{\"dependencies\":[{\"name\":\"winapi-util\",\"req\":\"^0.1.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "terminal_size_0.4.3": "{\"dependencies\":[{\"features\":[\"termios\"],\"name\":\"rustix\",\"req\":\"^1.0.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\"^0.60.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -1587,10 +1596,11 @@ "time-core_0.1.8": "{\"dependencies\":[],\"features\":{\"large-dates\":[]}}", "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", - "timezone_provider_0.1.2": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"std\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.0.18\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", + "timezone_provider_0.2.3": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"compiled_data\"],\"kind\":\"dev\",\"name\":\"icu_time\",\"req\":\"^2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\",\"unstable\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[\"dep:zerofrom\",\"zerofrom/derive\"],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", "tiny-keccak_2.0.2": "{\"dependencies\":[{\"name\":\"crunchy\",\"req\":\"^0.2.2\"}],\"features\":{\"cshake\":[],\"default\":[],\"fips202\":[\"keccak\",\"shake\",\"sha3\"],\"k12\":[],\"keccak\":[],\"kmac\":[\"cshake\"],\"parallel_hash\":[\"cshake\"],\"sha3\":[],\"shake\":[],\"sp800\":[\"cshake\",\"kmac\",\"tuple_hash\"],\"tuple_hash\":[\"cshake\"]}}", "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", "tinystr_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", + "tinystr_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", "tinyvec_1.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"generic-array\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"tinyvec_macros\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"alloc\":[\"tinyvec_macros\"],\"debugger_visualizer\":[],\"default\":[],\"experimental_write_impl\":[],\"grab_spare_slice\":[],\"latest_stable_rust\":[\"rustc_1_61\"],\"nightly_slice_partition_dedup\":[],\"real_blackbox\":[\"criterion/real_blackbox\"],\"rustc_1_40\":[],\"rustc_1_55\":[],\"rustc_1_57\":[],\"rustc_1_61\":[\"rustc_1_57\"],\"std\":[\"alloc\"]}}", "tinyvec_macros_0.1.1": "{\"dependencies\":[],\"features\":{}}", "tokio-graceful_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\",\"target\":\"cfg(not(loom))\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"server-auto\",\"http1\",\"http2\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"futures\",\"checkpoint\"],\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"slab\",\"req\":\"^0.4\"},{\"features\":[\"rt\",\"signal\",\"sync\",\"macros\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"net\",\"rt-multi-thread\",\"io-util\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{}}", @@ -1677,7 +1687,7 @@ "utf8_iter_1.0.4": "{\"dependencies\":[],\"features\":{}}", "utf8parse_0.2.2": "{\"dependencies\":[],\"features\":{\"default\":[],\"nightly\":[]}}", "uuid_1.20.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"atomic\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.20.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.56\"},{\"default_features\":false,\"name\":\"sha1_smol\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"slog\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.52\"},{\"name\":\"uuid-rng-internal-lib\",\"optional\":true,\"package\":\"uuid-rng-internal\",\"req\":\"^1.20.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"default_features\":false,\"features\":[\"msrv\"],\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"atomic\":[\"dep:atomic\"],\"borsh\":[\"dep:borsh\",\"dep:borsh-derive\"],\"default\":[\"std\"],\"fast-rng\":[\"rng\",\"dep:rand\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"macro-diagnostics\":[],\"md5\":[\"dep:md-5\"],\"rng\":[\"dep:getrandom\"],\"rng-getrandom\":[\"rng\",\"dep:getrandom\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/getrandom\"],\"rng-rand\":[\"rng\",\"dep:rand\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/rand\"],\"serde\":[\"dep:serde_core\"],\"sha1\":[\"dep:sha1_smol\"],\"std\":[\"wasm-bindgen?/std\",\"js-sys?/std\"],\"v1\":[\"atomic\"],\"v3\":[\"md5\"],\"v4\":[\"rng\"],\"v5\":[\"sha1\"],\"v6\":[\"atomic\"],\"v7\":[\"rng\"],\"v8\":[]}}", - "v8_146.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", + "v8_147.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"simdutf\":[],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", "version-compare_0.2.1": "{\"dependencies\":[],\"features\":{}}", @@ -1813,7 +1823,9 @@ "yansi_1.0.1": "{\"dependencies\":[{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.11\"}],\"features\":{\"_nightly\":[],\"alloc\":[],\"default\":[\"std\"],\"detect-env\":[\"std\"],\"detect-tty\":[\"is-terminal\",\"std\"],\"hyperlink\":[\"std\"],\"std\":[\"alloc\"]}}", "yasna_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.1\"}],\"features\":{\"default\":[],\"std\":[]}}", "yoke-derive_0.8.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", + "yoke-derive_0.8.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", "yoke_0.8.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"yoke-derive\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"}],\"features\":{\"alloc\":[\"stable_deref_trait/alloc\",\"zerofrom/alloc\"],\"default\":[\"alloc\",\"zerofrom\"],\"derive\":[\"dep:yoke-derive\",\"zerofrom/derive\"],\"serde\":[],\"zerofrom\":[\"dep:zerofrom\"]}}", + "yoke_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"yoke-derive\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"}],\"features\":{\"alloc\":[\"stable_deref_trait/alloc\",\"zerofrom/alloc\"],\"default\":[\"alloc\",\"zerofrom\"],\"derive\":[\"dep:yoke-derive\",\"zerofrom/derive\"],\"serde\":[],\"zerofrom\":[\"dep:zerofrom\"]}}", "zbus_4.4.0": "{\"dependencies\":[{\"name\":\"async-broadcast\",\"req\":\"^0.7.0\"},{\"name\":\"async-executor\",\"optional\":true,\"req\":\"^1.11.0\"},{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1.2\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.3.2\"},{\"name\":\"async-lock\",\"optional\":true,\"req\":\"^3.3.0\"},{\"name\":\"async-process\",\"req\":\"^2.2.2\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"async-recursion\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os = \\\"macos\\\", windows))\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"name\":\"async-trait\",\"req\":\"^0.1.80\"},{\"name\":\"blocking\",\"optional\":true,\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"serde\"],\"name\":\"enumflags2\",\"req\":\"^0.7.9\"},{\"name\":\"event-listener\",\"req\":\"^5.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.30\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.30\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"socket\",\"uio\",\"user\"],\"name\":\"nix\",\"req\":\"^0.29\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.2\"},{\"name\":\"ordered-stream\",\"req\":\"^0.2\"},{\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.200\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"features\":[\"std\"],\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"default_features\":false,\"features\":[\"trace\"],\"kind\":\"dev\",\"name\":\"test-log\",\"req\":\"^0.2.16\"},{\"features\":[\"rt\",\"net\",\"time\",\"fs\",\"io-util\",\"process\",\"sync\",\"tracing\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.37.0\"},{\"features\":[\"macros\",\"rt-multi-thread\",\"fs\",\"io-util\",\"net\",\"sync\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.37.0\"},{\"name\":\"tokio-vsock\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.18\"},{\"name\":\"uds_windows\",\"req\":\"^1.1.0\",\"target\":\"cfg(windows)\"},{\"name\":\"vsock\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\",\"Win32_System_Memory\",\"Win32_Networking\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"},{\"name\":\"xdg-home\",\"req\":\"^1.1.0\"},{\"name\":\"zbus_macros\",\"req\":\"=4.4.0\"},{\"name\":\"zbus_names\",\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"zbus_xml\",\"req\":\"^4.0.0\"},{\"default_features\":false,\"features\":[\"enumflags2\"],\"name\":\"zvariant\",\"req\":\"^4.2.0\"}],\"features\":{\"async-io\":[\"dep:async-io\",\"async-executor\",\"async-task\",\"async-lock\",\"async-fs\",\"blocking\",\"futures-util/io\"],\"bus-impl\":[\"p2p\"],\"chrono\":[\"zvariant/chrono\"],\"default\":[\"async-io\"],\"heapless\":[\"zvariant/heapless\"],\"option-as-array\":[\"zvariant/option-as-array\"],\"p2p\":[],\"time\":[\"zvariant/time\"],\"tokio\":[\"dep:tokio\"],\"tokio-vsock\":[\"dep:tokio-vsock\",\"tokio\"],\"url\":[\"zvariant/url\"],\"uuid\":[\"zvariant/uuid\"],\"vsock\":[\"dep:vsock\",\"dep:async-io\"]}}", "zbus_macros_4.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.3.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro-crate\",\"req\":\"^3.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.200\"},{\"features\":[\"extra-traits\",\"fold\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.93\"},{\"name\":\"zvariant_utils\",\"req\":\"=2.1.0\"}],\"features\":{}}", "zbus_names_3.0.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"enumflags2\"],\"name\":\"zvariant\",\"req\":\"^4.0.0\"}],\"features\":{}}", @@ -1826,14 +1838,17 @@ "zeroize_1.8.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"zeroize_derive\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"aarch64\":[],\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"zeroize_derive\"],\"simd\":[],\"std\":[\"alloc\"]}}", "zeroize_derive_1.4.3": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\",\"visit\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "zerotrie_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"]}}", + "zerotrie_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec?/alloc\"],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"dense\":[\"dep:zerovec\"],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"],\"zerovec\":[\"dep:zerovec\"]}}", "zerovec-derive_0.11.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", + "zerovec-derive_0.11.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", + "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", - "zoneinfo64_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", + "zoneinfo64_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", "zstd-safe_5.0.2+zstd.1.5.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.21\"},{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.1\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2cbc80c513..1950939e65 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1518,9 +1518,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "calendrical_calculations" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" dependencies = [ "core_maths", "displaydoc", @@ -5099,9 +5099,9 @@ dependencies = [ [[package]] name = "diplomat" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +checksum = "7935649d00000f5c5d735448ad3dc07b9738160727017914cf42138b8e8e6611" dependencies = [ "diplomat_core", "proc-macro2", @@ -5111,15 +5111,15 @@ dependencies = [ [[package]] name = "diplomat-runtime" -version = "0.14.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" +checksum = "970ac38ad677632efcee6d517e783958da9bc78ec206d8d5e35b459ffc5e4864" [[package]] name = "diplomat_core" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +checksum = "9cf41b94101a4bce993febaf0098092b0bb31deaf0ecaf6e0a2562465f61b383" dependencies = [ "proc-macro2", "quote", @@ -5663,9 +5663,9 @@ dependencies = [ [[package]] name = "fixed_decimal" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" dependencies = [ "displaydoc", "smallvec", @@ -7508,9 +7508,9 @@ dependencies = [ [[package]] name = "icu_calendar" -version = "2.1.1" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" dependencies = [ "calendrical_calculations", "displaydoc", @@ -7524,18 +7524,19 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" +checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -7543,14 +7544,16 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" +checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" dependencies = [ + "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", + "icu_plurals", "icu_provider", "writeable", "zerovec", @@ -7558,15 +7561,15 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" +checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" [[package]] name = "icu_locale" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" dependencies = [ "icu_collections", "icu_locale_core", @@ -7579,9 +7582,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -7593,15 +7596,15 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -7613,15 +7616,34 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_plurals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -7633,15 +7655,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -10896,9 +10918,9 @@ dependencies = [ [[package]] name = "resb" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" dependencies = [ "potential_utf", "serde_core", @@ -12625,14 +12647,14 @@ dependencies = [ [[package]] name = "temporal_capi" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" +checksum = "8a2a1f001e756a9f5f2d175a9965c4c0b3a054f09f30de3a75ab49765f2deb36" dependencies = [ "diplomat", "diplomat-runtime", "icu_calendar", - "icu_locale", + "icu_locale_core", "num-traits", "temporal_rs", "timezone_provider", @@ -12642,13 +12664,14 @@ dependencies = [ [[package]] name = "temporal_rs" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1" +checksum = "9a902a45282e5175186b21d355efc92564601efe6e2d92818dc9e333d50bd4de" dependencies = [ + "calendrical_calculations", "core_maths", "icu_calendar", - "icu_locale", + "icu_locale_core", "ixdtf", "num-traits", "timezone_provider", @@ -12865,9 +12888,9 @@ dependencies = [ [[package]] name = "timezone_provider" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +checksum = "c48f9b04628a2b813051e4dfe97c65281e49625eabd09ec343190e31e399a8c2" dependencies = [ "tinystr", "zerotrie", @@ -12898,9 +12921,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "serde_core", @@ -13714,9 +13737,9 @@ dependencies = [ [[package]] name = "v8" -version = "146.4.0" +version = "147.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1" +checksum = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd" dependencies = [ "bindgen", "bitflags 2.10.0", @@ -14866,9 +14889,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -14877,9 +14900,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -15012,20 +15035,21 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", "yoke", @@ -15035,9 +15059,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -15108,9 +15132,9 @@ checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zoneinfo64" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" dependencies = [ "calendrical_calculations", "icu_locale_core", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index efc64577dd..ec38a87cff 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -413,7 +413,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" -v8 = "=146.4.0" +v8 = "=147.4.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" diff --git a/codex-rs/v8-poc/src/lib.rs b/codex-rs/v8-poc/src/lib.rs index f43a9a1253..5103cdeb39 100644 --- a/codex-rs/v8-poc/src/lib.rs +++ b/codex-rs/v8-poc/src/lib.rs @@ -78,4 +78,15 @@ mod tests { fn evaluates_string_concatenation() { assert_eq!(evaluate_expression("'hello ' + 'world'"), "hello world"); } + + #[test] + fn parses_crdtp_dispatchable_messages() { + let cbor = v8::crdtp::json_to_cbor(br#"{"id":7,"method":"Runtime.evaluate","params":{}}"#) + .expect("JSON should convert to CBOR"); + let dispatchable = v8::crdtp::Dispatchable::new(&cbor); + + assert!(dispatchable.ok()); + assert_eq!(dispatchable.call_id(), 7); + assert_eq!(dispatchable.method(), b"Runtime.evaluate"); + } } diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index b58292c727..72d6990790 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -4,6 +4,7 @@ exports_files([ "aws-lc-sys_windows_msvc_prebuilt_nasm.patch", "aws-lc-sys_windows_msvc_memcmp_probe.patch", "bzip2_windows_stack_args.patch", + "llvm_rusty_v8_custom_libcxx.patch", "llvm_windows_symlink_extract.patch", "rules_rust_windows_bootstrap_process_wrapper_linker.patch", "rules_rust_windows_build_script_runner_paths.patch", @@ -12,6 +13,7 @@ exports_files([ "rules_rust_windows_process_wrapper_skip_temp_outputs.patch", "rules_rust_windows_msvc_direct_link_args.patch", "rules_rust_windows_gnullvm_build_script.patch", + "rules_cc_rusty_v8_custom_libcxx.patch", "rules_rs_windows_gnullvm_exec.patch", "rules_rs_windows_exec_linker.patch", "rusty_v8_prebuilt_out_dir.patch", diff --git a/patches/llvm_rusty_v8_custom_libcxx.patch b/patches/llvm_rusty_v8_custom_libcxx.patch new file mode 100644 index 0000000000..c30a28ac63 --- /dev/null +++ b/patches/llvm_rusty_v8_custom_libcxx.patch @@ -0,0 +1,79 @@ +diff --git a/toolchain/BUILD.bazel b/toolchain/BUILD.bazel +index fa00156236..bf0ee9d368 100644 +--- a/toolchain/BUILD.bazel ++++ b/toolchain/BUILD.bazel +@@ -222,11 +222,16 @@ cc_args_list( + + # TODO(cerisier): extract those into proper semantic args list. + # TODO(zbarsky): This must match llvm/toolchains/llvm.bzl ++_DEFAULT_LIBCXX_HEADER_ARGS = select({ ++ "@@//third_party/v8:use_rusty_v8_custom_libcxx": [], ++ "//conditions:default": [ ++ "//toolchain/args:libcxx_headers_include_search_paths", ++ ], ++}) ++ + cc_args_list( + name = "linux_toolchain_args", +- args = [ +- "//toolchain/args:libcxx_headers_include_search_paths", +- ] + select({ ++ args = _DEFAULT_LIBCXX_HEADER_ARGS + select({ + "//platforms/config:musl": [ + "//toolchain/args/linux:kernel_headers_include_search_paths", + "//toolchain/args/linux:musl_libc_headers_include_search_paths", +@@ -252,8 +257,7 @@ cc_args_list( + # TODO(zbarsky): This must match llvm/toolchains/llvm.bzl + cc_args_list( + name = "windows_toolchain_args", +- args = [ +- "//toolchain/args:libcxx_headers_include_search_paths", ++ args = _DEFAULT_LIBCXX_HEADER_ARGS + [ + "//toolchain/args/windows:mingw_headers_include_search_paths", + ], + ) +diff --git a/toolchain/llvm/llvm.bzl b/toolchain/llvm/llvm.bzl +index d36d8b94bd..97aa879d4a 100644 +--- a/toolchain/llvm/llvm.bzl ++++ b/toolchain/llvm/llvm.bzl +@@ -186,16 +186,22 @@ def declare_llvm_targets(*, suffix = ""): + ], + ) + ++ default_libcxx_target_headers = select({ ++ "@@//third_party/v8:use_rusty_v8_custom_libcxx": [], ++ "//conditions:default": [ ++ "@llvm//runtimes/libcxx:libcxx_headers_include_search_directory", ++ "@llvm//runtimes/libcxx:libcxxabi_headers_include_search_directory", ++ ], ++ }) ++ + # This must match //toolchain:linux_toolchain_args + include_path( + name = "linux_target_headers", + srcs = [ + ":builtin_resource_dir", +- "@llvm//runtimes/libcxx:libcxx_headers_include_search_directory", +- "@llvm//runtimes/libcxx:libcxxabi_headers_include_search_directory", + "@kernel_headers//:kernel_headers_directory", + "@llvm//sanitizers:sanitizers_headers_include_search_directory", +- ] + select({ ++ ] + default_libcxx_target_headers + select({ + "@llvm//platforms/config:musl": [ + "@llvm//runtimes/musl:musl_headers_include_search_directory", + ], +@@ -210,13 +216,11 @@ def declare_llvm_targets(*, suffix = ""): + name = "windows_target_headers", + srcs = [ + ":builtin_resource_dir", +- "@llvm//runtimes/libcxx:libcxx_headers_include_search_directory", +- "@llvm//runtimes/libcxx:libcxxabi_headers_include_search_directory", + "@mingw//:mingw_generated_headers_crt_directory", + "@mingw//:mingw_w64_headers_include_directory", + "@mingw//:mingw_w64_headers_crt_directory", + "@mingw//:mingw_w64_winpthreads_include_directory", +- ], ++ ] + default_libcxx_target_headers, + ) + + include_path( diff --git a/patches/rules_cc_rusty_v8_custom_libcxx.patch b/patches/rules_cc_rusty_v8_custom_libcxx.patch new file mode 100644 index 0000000000..3efda03525 --- /dev/null +++ b/patches/rules_cc_rusty_v8_custom_libcxx.patch @@ -0,0 +1,44 @@ +diff --git a/cc/cc_library.bzl b/cc/cc_library.bzl +index c45c5fd954..2e3a01f12a 100644 +--- a/cc/cc_library.bzl ++++ b/cc/cc_library.bzl +@@ -15,5 +15,39 @@ + + load("@cc_compatibility_proxy//:proxy.bzl", _cc_library = "cc_library") + ++_RUSTY_V8_CUSTOM_LIBCXX_COPTS = select({ ++ "@@//third_party/v8:use_rusty_v8_custom_libcxx": [ ++ "-nostdinc++", ++ "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", ++ "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", ++ "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", ++ "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", ++ ], ++ "//conditions:default": [], ++}) ++ ++_RUSTY_V8_CUSTOM_LIBCXX_DEPS = select({ ++ "@@//third_party/v8:use_rusty_v8_custom_libcxx": [ ++ "@@//third_party/v8:rusty_v8_custom_libcxx_headers", ++ ], ++ "//conditions:default": [], ++}) ++ ++_RUSTY_V8_CUSTOM_LIBCXX_REPOS = [ ++ # V8 and ICU are patched in-tree. These external C++ dependencies still ++ # build inside the artifact closure once the LLVM toolchain's default ++ # libc++ headers are suppressed. ++ "@abseil-cpp+", ++ "@v8++http_archive+highway", ++ "@v8++http_archive+simdutf", ++] ++ ++def _should_use_rusty_v8_custom_libcxx(): ++ return native.repository_name() in _RUSTY_V8_CUSTOM_LIBCXX_REPOS ++ + def cc_library(**kwargs): ++ if _should_use_rusty_v8_custom_libcxx(): ++ kwargs["copts"] = (kwargs.get("copts", []) or []) + _RUSTY_V8_CUSTOM_LIBCXX_COPTS ++ kwargs["deps"] = (kwargs.get("deps", []) or []) + _RUSTY_V8_CUSTOM_LIBCXX_DEPS ++ kwargs["features"] = (kwargs.get("features", []) or []) + ["-module_maps"] + _cc_library(**kwargs) diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 70ab440d85..1c49d06b09 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -7,16 +7,47 @@ diff --git a/orig/v8-14.6.202.11/bazel/defs.bzl b/mod/v8-14.6.202.11/bazel/defs. index 9648e4a..88efd41 100644 --- a/orig/v8-14.6.202.11/bazel/defs.bzl +++ b/mod/v8-14.6.202.11/bazel/defs.bzl -@@ -97,7 +97,7 @@ v8_config = rule( +@@ -33,9 +33,21 @@ + ) + + def v8_flag(name, default = False): +- _create_option_flag(name = name, build_setting_default = default) +- native.config_setting(name = "is_" + name, flag_values = {name: "True"}) +- native.config_setting(name = "is_not_" + name, flag_values = {name: "False"}) ++ _create_option_flag( ++ name = name, ++ build_setting_default = default, ++ visibility = ["//visibility:public"], ++ ) ++ native.config_setting( ++ name = "is_" + name, ++ flag_values = {name: "True"}, ++ visibility = ["//visibility:public"], ++ ) ++ native.config_setting( ++ name = "is_not_" + name, ++ flag_values = {name: "False"}, ++ visibility = ["//visibility:public"], ++ ) + + def v8_string(name, default = ""): + _create_option_string(name = name, build_setting_default = default) +@@ -97,7 +109,13 @@ def _default_args(): return struct( - deps = [":define_flags", "@libcxx//:libc++"], -+ deps = [":define_flags"], ++ deps = [":define_flags"] + select({ ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "@@//third_party/v8:rusty_v8_custom_libcxx_headers", ++ "@@//third_party/v8:rusty_v8_custom_libcxx_runtime", ++ ], ++ "//conditions:default": [], ++ }), defines = select({ "@v8//bazel/config:is_windows": [ "UNICODE", -@@ -128,12 +128,6 @@ def _default_args(): +@@ -127,12 +145,15 @@ ], "//conditions:default": [], }) + select({ @@ -26,20 +57,29 @@ index 9648e4a..88efd41 100644 - "-Wno-deprecated-declarations", - "-std=c++20", - ], ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "-nostdinc++", ++ "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", ++ "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", ++ "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", ++ "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", ++ ], ++ "//conditions:default": [], ++ }) + select({ "@v8//bazel/config:is_gcc": [ "-Wno-extra", "-Wno-array-bounds", -@@ -155,7 +149,15 @@ def _default_args(): -- "@v8//bazel/config:is_windows": [ +@@ -152,9 +173,17 @@ + "-std=gnu++2a", + ], + "@v8//bazel/config:is_windows": [ - "/std:c++20", -- ], -- "//conditions:default": [], -+ "@v8//bazel/config:is_windows": [ + "-Wno-invalid-offsetof", + "-Wno-deprecated-this-capture", + "-Wno-deprecated-declarations", + "-std=c++20", -+ ], + ], +- "//conditions:default": [], + "//conditions:default": [ + "-Wno-invalid-offsetof", + "-Wno-deprecated-this-capture", @@ -49,7 +89,9 @@ index 9648e4a..88efd41 100644 }) + select({ "@v8//bazel/config:is_gcc_fastbuild": [ # Non-debug builds without optimizations fail because -@@ -180,10 +179,10 @@ def _default_args(): +@@ -178,12 +207,12 @@ + includes = ["include"], + linkopts = select({ "@v8//bazel/config:is_windows": [ - "Winmm.lib", - "DbgHelp.lib", @@ -65,19 +107,21 @@ index 9648e4a..88efd41 100644 ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel -index 85f31b7..bbc351b 100644 +index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel +++ b/mod/v8-14.6.202.11/BUILD.bazel -@@ -148,6 +148,8 @@ v8_flag(name = "v8_enable_trace_maps") +@@ -148,6 +148,10 @@ v8_flag(name = "v8_enable_trace_maps") v8_flag(name = "v8_enable_v8_checks") +v8_flag(name = "v8_enable_sandbox") ++ ++v8_flag(name = "v8_use_rusty_v8_custom_libcxx") + v8_flag(name = "v8_enable_verify_csa") v8_flag(name = "v8_enable_verify_heap") -@@ -303,7 +305,7 @@ v8_int( +@@ -313,7 +317,7 @@ v8_int( # If no explicit value for v8_enable_pointer_compression, we set it to 'none'. v8_string( name = "v8_enable_pointer_compression", @@ -86,7 +130,7 @@ index 85f31b7..bbc351b 100644 ) # Default setting for v8_enable_pointer_compression. -@@ -503,6 +505,7 @@ v8_config( +@@ -513,6 +517,7 @@ v8_config( "v8_enable_slow_dchecks": "ENABLE_SLOW_DCHECKS", "v8_enable_runtime_call_stats": "V8_RUNTIME_CALL_STATS", "v8_enable_snapshot_native_code_counters": "V8_SNAPSHOT_NATIVE_CODE_COUNTERS", @@ -94,7 +138,7 @@ index 85f31b7..bbc351b 100644 "v8_enable_trace_maps": "V8_TRACE_MAPS", "v8_enable_turbofan": "V8_ENABLE_TURBOFAN", "v8_enable_v8_checks": "V8_ENABLE_CHECKS", -@@ -4077,28 +4080,14 @@ filegroup( +@@ -4098,28 +4103,14 @@ filegroup( }), ) @@ -129,7 +173,7 @@ index 85f31b7..bbc351b 100644 ) filegroup( -@@ -4405,6 +4394,20 @@ genrule( +@@ -4422,6 +4413,20 @@ genrule( srcs = [ "include/js_protocol.pdl", "src/inspector/inspector_protocol_config.json", @@ -150,7 +194,7 @@ index 85f31b7..bbc351b 100644 ], outs = [ "include/inspector/Debugger.h", -@@ -4426,15 +4429,19 @@ genrule( +@@ -4443,15 +4448,19 @@ genrule( "src/inspector/protocol/Schema.cpp", "src/inspector/protocol/Schema.h", ], @@ -174,7 +218,7 @@ index 85f31b7..bbc351b 100644 ], ) -@@ -4448,6 +4455,15 @@ filegroup( +@@ -4465,6 +4474,35 @@ filegroup( ], ) @@ -186,11 +230,31 @@ index 85f31b7..bbc351b 100644 + strip_include_prefix = "", + visibility = ["//visibility:public"], +) ++ ++cc_library( ++ name = "rusty_v8_crdtp_headers", ++ hdrs = [ ++ "third_party/inspector_protocol/crdtp/cbor.h", ++ "third_party/inspector_protocol/crdtp/dispatch.h", ++ "third_party/inspector_protocol/crdtp/error_support.h", ++ "third_party/inspector_protocol/crdtp/export.h", ++ "third_party/inspector_protocol/crdtp/find_by_first.h", ++ "third_party/inspector_protocol/crdtp/frontend_channel.h", ++ "third_party/inspector_protocol/crdtp/json.h", ++ "third_party/inspector_protocol/crdtp/parser_handler.h", ++ "third_party/inspector_protocol/crdtp/protocol_core.h", ++ "third_party/inspector_protocol/crdtp/serializable.h", ++ "third_party/inspector_protocol/crdtp/span.h", ++ "third_party/inspector_protocol/crdtp/status.h", ++ ], ++ strip_include_prefix = "", ++ visibility = ["//visibility:public"], ++) + filegroup( name = "d8_files", srcs = [ -@@ -4567,16 +4583,9 @@ cc_library( +@@ -4584,16 +4602,9 @@ cc_library( ], ) @@ -210,7 +274,7 @@ index 85f31b7..bbc351b 100644 ) v8_library( -@@ -4593,7 +4602,7 @@ v8_library( +@@ -4610,7 +4621,7 @@ v8_library( copts = ["-Wno-implicit-fallthrough"], icu_deps = [ ":icu/generated_torque_definitions_headers", @@ -219,7 +283,7 @@ index 85f31b7..bbc351b 100644 ], icu_srcs = [ ":generated_regexp_special_case", -@@ -4608,7 +4617,7 @@ v8_library( +@@ -4625,7 +4636,7 @@ v8_library( ], deps = [ ":lib_dragonbox", @@ -228,7 +292,7 @@ index 85f31b7..bbc351b 100644 ":lib_fp16", ":simdutf", ":v8_libbase", -@@ -4664,6 +4673,7 @@ alias( +@@ -4681,6 +4692,7 @@ alias( alias( name = "core_lib_icu", actual = "icu/v8", @@ -236,7 +300,7 @@ index 85f31b7..bbc351b 100644 ) v8_library( -@@ -4715,7 +4725,7 @@ v8_binary( +@@ -4732,7 +4744,7 @@ v8_binary( ], deps = [ ":v8_libbase", @@ -245,17 +309,53 @@ index 85f31b7..bbc351b 100644 ], ) +@@ -4772,9 +4784,15 @@ v8_binary( + ":icu/generated_torque_initializers", + ":icu/v8_initializers_files", + ], ++ # Match GN's mksnapshot `disable_icf` config. If the linker folds distinct ++ # external-reference helpers together while producing the snapshot, the ++ # final embedder binary may not fold the same pair and startup ++ # deserialization will reject the snapshot. + linkopts = select({ + "@v8//bazel/config:is_android": ["-llog"], +- "//conditions:default": [], ++ "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], ++ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "//conditions:default": ["-Wl,--icf=none"], + }), + noicu_deps = [":v8_libshared_noicu"], + noicu_srcs = [ diff --git a/orig/v8-14.6.202.11/bazel/BUILD.icu b/mod/v8-14.6.202.11/bazel/BUILD.icu -index 5fda2f4..381386c 100644 +index 5fda2f4..9729451 100644 --- a/orig/v8-14.6.202.11/bazel/BUILD.icu +++ b/mod/v8-14.6.202.11/bazel/BUILD.icu -@@ -1,3 +1,5 @@ +@@ -1,3 +1,24 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") ++ ++CUSTOM_LIBCXX_COPTS = select({ ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "-nostdinc++", ++ "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", ++ "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", ++ "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", ++ "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", ++ ], ++ "//conditions:default": [], ++}) ++ ++CUSTOM_LIBCXX_DEPS = select({ ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "@@//third_party/v8:rusty_v8_custom_libcxx_headers", ++ "@@//third_party/v8:rusty_v8_custom_libcxx_runtime", ++ ], ++ "//conditions:default": [], ++}) + # Copyright 2021 the V8 project authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. -@@ -16,10 +18,7 @@ cc_library( +@@ -16,15 +37,12 @@ ]), copts = select({ "@platforms//os:windows": [ @@ -267,7 +367,21 @@ index 5fda2f4..381386c 100644 ], "//conditions:default": [ "-Wno-deprecated-declarations", -@@ -65,10 +64,7 @@ cc_library( + ], +- }), ++ }) + CUSTOM_LIBCXX_COPTS, + data = [":icudata"], + defines = [ + "HAVE_DLOPEN=0", +@@ -54,6 +72,7 @@ + "U_ICUDATAENTRY_IN_COMMON", + ], + tags = ["requires-rtti"], ++ deps = CUSTOM_LIBCXX_DEPS, + alwayslink = 1, + ) + +@@ -65,19 +84,16 @@ ]), copts = select({ "@platforms//os:windows": [ @@ -279,7 +393,18 @@ index 5fda2f4..381386c 100644 ], "//conditions:default": [ "-Wno-deprecated-declarations", -@@ -93,10 +89,7 @@ cc_library( + ], +- }), ++ }) + CUSTOM_LIBCXX_COPTS, + local_defines = [ + "U_I18N_IMPLEMENTATION", + ], +- deps = [":icuuc"], ++ deps = [":icuuc"] + CUSTOM_LIBCXX_DEPS, + alwayslink = 1, + ) + +@@ -93,13 +109,10 @@ ]), copts = select({ "@platforms//os:windows": [ @@ -290,4 +415,16 @@ index 5fda2f4..381386c 100644 + "-Wno-deprecated-declarations", ], "//conditions:default": [], - }), +- }), ++ }) + CUSTOM_LIBCXX_COPTS, + include_prefix = "third_party/icu", + local_defines = [ + "U_COMMON_IMPLEMENTATION", +@@ -108,6 +121,6 @@ + deps = [ + ":icui18n", + ":icuuc", +- ], ++ ] + CUSTOM_LIBCXX_DEPS, + alwayslink = 1, + ) diff --git a/third_party/v8/BUILD.bazel b/third_party/v8/BUILD.bazel index 94bb3b32c6..459d935854 100644 --- a/third_party/v8/BUILD.bazel +++ b/third_party/v8/BUILD.bazel @@ -22,57 +22,34 @@ config_setting( ], ) -alias( - name = "v8_146_4_0_x86_64_apple_darwin", - actual = "@rusty_v8_146_4_0_x86_64_apple_darwin_archive//file", +config_setting( + name = "use_rusty_v8_custom_libcxx", + flag_values = { + "@v8//:v8_use_rusty_v8_custom_libcxx": "True", + }, ) alias( - name = "v8_146_4_0_aarch64_apple_darwin", - actual = "@rusty_v8_146_4_0_aarch64_apple_darwin_archive//file", + name = "v8_147_4_0_x86_64_pc_windows_msvc", + actual = "@rusty_v8_147_4_0_x86_64_pc_windows_msvc_archive//file", ) alias( - name = "v8_146_4_0_x86_64_unknown_linux_gnu", - actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive//file", + name = "v8_147_4_0_aarch64_pc_windows_msvc", + actual = "@rusty_v8_147_4_0_aarch64_pc_windows_msvc_archive//file", ) alias( - name = "v8_146_4_0_aarch64_unknown_linux_gnu", - actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive//file", -) - -alias( - name = "v8_146_4_0_x86_64_unknown_linux_musl", - actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive//file", -) - -alias( - name = "v8_146_4_0_aarch64_unknown_linux_musl", - actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive//file", -) - -alias( - name = "v8_146_4_0_x86_64_pc_windows_msvc", - actual = "@rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive//file", -) - -alias( - name = "v8_146_4_0_aarch64_pc_windows_msvc", - actual = "@rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive//file", -) - -alias( - name = "v8_146_4_0_aarch64_pc_windows_gnullvm", + name = "v8_147_4_0_aarch64_pc_windows_gnullvm", # `rusty_v8` only ships prebuilt Windows archives for MSVC. Build the # GNU-flavored archive in-tree so windows-gnullvm consumers can link # against a matching ABI instead of trying to reuse the MSVC release. - actual = ":v8_146_4_0_aarch64_pc_windows_gnullvm_bazel", + actual = ":v8_147_4_0_aarch64_pc_windows_gnullvm_bazel", ) alias( - name = "v8_146_4_0_x86_64_pc_windows_gnullvm", - actual = ":v8_146_4_0_x86_64_pc_windows_gnullvm_bazel", + name = "v8_147_4_0_x86_64_pc_windows_gnullvm", + actual = ":v8_147_4_0_x86_64_pc_windows_gnullvm_bazel", ) filegroup( @@ -96,80 +73,94 @@ filegroup( ) alias( - name = "src_binding_release_x86_64_unknown_linux_musl", - actual = "@rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding//file", -) - -alias( - name = "src_binding_release_aarch64_unknown_linux_musl", - actual = "@rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding//file", -) - -filegroup( - name = "src_binding_release_x86_64_pc_windows_msvc", - srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_pc_windows_msvc"], -) - -filegroup( - name = "src_binding_release_aarch64_pc_windows_msvc", - srcs = ["@v8_crate_146_4_0//:src_binding_release_aarch64_pc_windows_msvc"], -) - -alias( - name = "src_binding_release_x86_64_pc_windows_gnullvm", + name = "src_binding_release_x86_64_pc_windows_gnullvm_147_4_0_release", # `rusty_v8` does not publish a Windows GNU binding file. The generated # binding only describes this V8 release's C++ API surface, so reuse the # Linux release binding while the windows-gnullvm archive build is still # experimental. - actual = ":src_binding_release_x86_64_unknown_linux_gnu", + actual = ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", ) alias( - name = "src_binding_release_aarch64_pc_windows_gnullvm", - actual = ":src_binding_release_aarch64_unknown_linux_gnu", + name = "src_binding_release_aarch64_pc_windows_gnullvm_147_4_0_release", + actual = ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", ) alias( name = "rusty_v8_archive_for_target", actual = select({ - "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":v8_146_4_0_aarch64_apple_darwin_bazel", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":v8_146_4_0_aarch64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":v8_146_4_0_aarch64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":v8_146_4_0_aarch64_unknown_linux_gnu_bazel", - ":platform_aarch64_unknown_linux_musl": ":v8_146_4_0_aarch64_unknown_linux_musl_release_base", - "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":v8_146_4_0_x86_64_apple_darwin_bazel", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":v8_146_4_0_x86_64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":v8_146_4_0_x86_64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":v8_146_4_0_x86_64_unknown_linux_gnu_bazel", - ":platform_x86_64_unknown_linux_musl": ":v8_146_4_0_x86_64_unknown_linux_musl_release", - "//conditions:default": ":v8_146_4_0_x86_64_unknown_linux_gnu_bazel", + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":v8_147_4_0_aarch64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":v8_147_4_0_aarch64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":v8_147_4_0_aarch64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", + ":platform_aarch64_unknown_linux_musl": ":v8_147_4_0_aarch64_unknown_linux_musl_release_base", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":v8_147_4_0_x86_64_apple_darwin_bazel", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":v8_147_4_0_x86_64_pc_windows_gnullvm", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":v8_147_4_0_x86_64_pc_windows_msvc", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + ":platform_x86_64_unknown_linux_musl": ":v8_147_4_0_x86_64_unknown_linux_musl_release", + "//conditions:default": ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", }), ) alias( name = "rusty_v8_binding_for_target", actual = select({ - "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":src_binding_release_aarch64_apple_darwin", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":src_binding_release_aarch64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":src_binding_release_aarch64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":src_binding_release_aarch64_unknown_linux_gnu", - ":platform_aarch64_unknown_linux_musl": ":src_binding_release_aarch64_unknown_linux_musl", - "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":src_binding_release_x86_64_apple_darwin", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":src_binding_release_x86_64_pc_windows_gnullvm", - "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":src_binding_release_x86_64_pc_windows_msvc", - "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":src_binding_release_x86_64_unknown_linux_gnu", - ":platform_x86_64_unknown_linux_musl": ":src_binding_release_x86_64_unknown_linux_musl", - "//conditions:default": ":src_binding_release_x86_64_unknown_linux_gnu", + "@rules_rs//rs/experimental/platforms/config:aarch64-apple-darwin": ":src_binding_release_aarch64_apple_darwin_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-gnullvm": ":src_binding_release_aarch64_pc_windows_gnullvm_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-pc-windows-msvc": ":src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:aarch64-unknown-linux-gnu": ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + ":platform_aarch64_unknown_linux_musl": ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-apple-darwin": ":src_binding_release_x86_64_apple_darwin_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-gnullvm": ":src_binding_release_x86_64_pc_windows_gnullvm_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-pc-windows-msvc": ":src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", + "@rules_rs//rs/experimental/platforms/config:x86_64-unknown-linux-gnu": ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + ":platform_x86_64_unknown_linux_musl": ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", + "//conditions:default": ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", }), ) V8_COPTS = ["-std=c++20"] +V8_CUSTOM_LIBCXX_COPTS = select({ + ":use_rusty_v8_custom_libcxx": [ + "-nostdinc++", + "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", + "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", + "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", + "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", + ], + "//conditions:default": [], +}) + V8_STATIC_LIBRARY_FEATURES = [ "-symbol_check", "-validate-static-library", ] +cc_library( + name = "rusty_v8_custom_libcxx_headers", + defines = select({ + ":platform_aarch64_unknown_linux_musl": ["ANDROID_HOST_MUSL"], + ":platform_x86_64_unknown_linux_musl": ["ANDROID_HOST_MUSL"], + "//conditions:default": [], + }), + deps = [ + "@rusty_v8_libcxx//:headers", + "@rusty_v8_libcxxabi//:headers", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "rusty_v8_custom_libcxx_runtime", + deps = [ + "@rusty_v8_libcxx//:libcxx", + "@rusty_v8_libcxxabi//:libcxxabi", + ], + visibility = ["//visibility:public"], +) + genrule( name = "binding_cc", srcs = ["@v8_crate_146_4_0//:binding_cc"], @@ -210,6 +201,65 @@ cc_library( ], ) +genrule( + name = "binding_cc_147_4_0", + srcs = ["@v8_crate_147_4_0//:binding_cc"], + outs = ["binding_147_4_0.cc"], + cmd = " ".join([ + "sed", + "-e '/#include \"v8\\/src\\/flags\\/flags.h\"/d'", + "-e 's|\"v8/src/libplatform/default-platform.h\"|\"src/libplatform/default-platform.h\"|'", + "-e 's|#include \"support.h\"|#include \"support_147_4_0.h\"|'", + "-e 's| namespace i = v8::internal;| (void)usage;|'", + "-e '/using HelpOptions = i::FlagList::HelpOptions;/d'", + "-e '/HelpOptions help_options = HelpOptions(HelpOptions::kExit, usage);/d'", + "-e 's| i::FlagList::SetFlagsFromCommandLine(argc, argv, true, help_options);| v8::V8::SetFlagsFromCommandLine(argc, argv, true);|'", + "$(location @v8_crate_147_4_0//:binding_cc)", + ">", + '"$@"', + ]), +) + +genrule( + name = "crdtp_binding_cc_147_4_0", + srcs = ["@v8_crate_147_4_0//:crdtp_binding_cc"], + outs = ["crdtp_binding_147_4_0.cc"], + cmd = " ".join([ + "sed", + "-e 's|#include \"support.h\"|#include \"support_147_4_0.h\"|'", + "-e 's|\"v8/third_party/inspector_protocol/|\"third_party/inspector_protocol/|g'", + "$(location @v8_crate_147_4_0//:crdtp_binding_cc)", + ">", + '"$@"', + ]), +) + +copy_file( + name = "support_h_147_4_0", + src = "@v8_crate_147_4_0//:support_h", + out = "support_147_4_0.h", +) + +cc_library( + name = "v8_147_4_0_binding", + srcs = [ + ":binding_cc_147_4_0", + ":crdtp_binding_cc_147_4_0", + ], + hdrs = [":support_h_147_4_0"], + copts = V8_COPTS + V8_CUSTOM_LIBCXX_COPTS, + deps = [ + "@v8//:core_lib_icu", + "@v8//:rusty_v8_crdtp_headers", + "@v8//:rusty_v8_internal_headers", + ] + select({ + ":use_rusty_v8_custom_libcxx": [ + ":rusty_v8_custom_libcxx_headers", + ], + "//conditions:default": [], + }), +) + cc_static_library( name = "v8_146_4_0_aarch64_apple_darwin_bazel", deps = [":v8_146_4_0_binding"], @@ -292,66 +342,244 @@ filegroup( srcs = ["@v8_crate_146_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], ) +cc_static_library( + name = "v8_147_4_0_aarch64_apple_darwin_bazel", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_aarch64_unknown_linux_gnu_bazel", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_aarch64_pc_windows_gnullvm_bazel", + deps = [":v8_147_4_0_binding"], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_aarch64_unknown_linux_musl_release_base", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +genrule( + name = "v8_147_4_0_aarch64_unknown_linux_musl_release", + srcs = [ + ":v8_147_4_0_aarch64_unknown_linux_musl_release_base", + "@llvm//runtimes/compiler-rt:clang_rt.builtins.static", + ], + tools = [ + "@llvm//tools:llvm-ar", + "@llvm//tools:llvm-ranlib", + ], + outs = ["libv8_147_4_0_aarch64_unknown_linux_musl.a"], + cmd = """ + cat > "$(@D)/merge.mri" <<'EOF' +create $@ +addlib $(location :v8_147_4_0_aarch64_unknown_linux_musl_release_base) +addlib $(location @llvm//runtimes/compiler-rt:clang_rt.builtins.static) +save +end +EOF + $(location @llvm//tools:llvm-ar) -M < "$(@D)/merge.mri" + $(location @llvm//tools:llvm-ranlib) "$@" + """, +) + +cc_static_library( + name = "v8_147_4_0_x86_64_apple_darwin_bazel", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_x86_64_pc_windows_gnullvm_bazel", + deps = [":v8_147_4_0_binding"], + features = V8_STATIC_LIBRARY_FEATURES, +) + +cc_static_library( + name = "v8_147_4_0_x86_64_unknown_linux_musl_release", + deps = [ + ":rusty_v8_custom_libcxx_runtime", + ":v8_147_4_0_binding", + ], + features = V8_STATIC_LIBRARY_FEATURES, +) + +filegroup( + name = "src_binding_release_aarch64_apple_darwin_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_apple_darwin"], +) + +filegroup( + name = "src_binding_release_x86_64_apple_darwin_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_apple_darwin"], +) + +filegroup( + name = "src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], +) + +filegroup( + name = "src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], +) + +filegroup( + name = "src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_unknown_linux_gnu"], +) + +filegroup( + name = "src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_unknown_linux_gnu"], +) + +filegroup( + name = "src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_aarch64_pc_windows_msvc"], +) + +filegroup( + name = "src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", + srcs = ["@v8_crate_147_4_0//:src_binding_release_x86_64_pc_windows_msvc"], +) + filegroup( name = "rusty_v8_release_pair_x86_64_apple_darwin", srcs = [ - ":v8_146_4_0_x86_64_apple_darwin", - ":src_binding_release_x86_64_apple_darwin", + ":v8_147_4_0_x86_64_apple_darwin_bazel", + ":src_binding_release_x86_64_apple_darwin_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_apple_darwin", srcs = [ - ":v8_146_4_0_aarch64_apple_darwin", - ":src_binding_release_aarch64_apple_darwin", + ":v8_147_4_0_aarch64_apple_darwin_bazel", + ":src_binding_release_aarch64_apple_darwin_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_unknown_linux_gnu", srcs = [ - ":v8_146_4_0_x86_64_unknown_linux_gnu", - ":src_binding_release_x86_64_unknown_linux_gnu", + ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_unknown_linux_gnu", srcs = [ - ":v8_146_4_0_aarch64_unknown_linux_gnu", - ":src_binding_release_aarch64_unknown_linux_gnu", + ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", + ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_unknown_linux_musl", srcs = [ - ":v8_146_4_0_x86_64_unknown_linux_musl_release", - ":src_binding_release_x86_64_unknown_linux_musl_release", + ":v8_147_4_0_x86_64_unknown_linux_musl_release", + ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_unknown_linux_musl", srcs = [ - ":v8_146_4_0_aarch64_unknown_linux_musl_release", - ":src_binding_release_aarch64_unknown_linux_musl_release", + ":v8_147_4_0_aarch64_unknown_linux_musl_release", + ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_x86_64_pc_windows_msvc", srcs = [ - ":v8_146_4_0_x86_64_pc_windows_msvc", - ":src_binding_release_x86_64_pc_windows_msvc", + ":v8_147_4_0_x86_64_pc_windows_msvc", + ":src_binding_release_x86_64_pc_windows_msvc_147_4_0_release", ], ) filegroup( name = "rusty_v8_release_pair_aarch64_pc_windows_msvc", srcs = [ - ":v8_146_4_0_aarch64_pc_windows_msvc", - ":src_binding_release_aarch64_pc_windows_msvc", + ":v8_147_4_0_aarch64_pc_windows_msvc", + ":src_binding_release_aarch64_pc_windows_msvc_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_x86_64_apple_darwin", + srcs = [ + ":v8_147_4_0_x86_64_apple_darwin_bazel", + ":src_binding_release_x86_64_apple_darwin_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_aarch64_apple_darwin", + srcs = [ + ":v8_147_4_0_aarch64_apple_darwin_bazel", + ":src_binding_release_aarch64_apple_darwin_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_gnu", + srcs = [ + ":v8_147_4_0_x86_64_unknown_linux_gnu_bazel", + ":src_binding_release_x86_64_unknown_linux_gnu_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_gnu", + srcs = [ + ":v8_147_4_0_aarch64_unknown_linux_gnu_bazel", + ":src_binding_release_aarch64_unknown_linux_gnu_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", + srcs = [ + ":v8_147_4_0_x86_64_unknown_linux_musl_release", + ":src_binding_release_x86_64_unknown_linux_musl_147_4_0_release", + ], +) + +filegroup( + name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_musl", + srcs = [ + ":v8_147_4_0_aarch64_unknown_linux_musl_release", + ":src_binding_release_aarch64_unknown_linux_musl_147_4_0_release", ], ) diff --git a/third_party/v8/README.md b/third_party/v8/README.md index f0961df1d0..336d03c74b 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -20,27 +20,39 @@ artifact contract. Current pinned versions: -- Rust crate: `v8 = =146.4.0` -- Embedded upstream V8 source for musl release builds: `14.6.202.9` +- Rust crate: `v8 = =147.4.0` +- Embedded upstream V8 source for Bazel-produced release builds: `14.7.173.20` -When bumping the Rust crate version, keep the checked-in checksum manifest and -`MODULE.bazel` in sync: +## Updating to a new `v8` release + +Use this as the maintainer flow for a version bump: + +1. Bump the `v8` crate version and refresh `codex-rs/Cargo.lock`. +2. Update the Bazel versioned inputs in `MODULE.bazel`, then refresh the + matching checksum manifest and generated checksums as described below. +3. Publish a release-candidate PR and validate that `v8-canary` passes. +4. If the canary is green, publish the release tag and release build. +5. Once the release build completes, rerun the build on the candidate branch + and verify that the final artifact builds and tests pass. + +When changing the remaining prebuilt `rusty_v8` `http_file` inputs, keep the +checked-in checksum manifest and `MODULE.bazel` in sync: ```bash python3 .github/scripts/rusty_v8_bazel.py update-module-bazel python3 .github/scripts/rusty_v8_bazel.py check-module-bazel ``` -The commands read `third_party/v8/rusty_v8_.sha256` by default -and validate every matching `rusty_v8_` `http_file` entry. -CI runs the check command to block checksum drift. +The commands default to the single `rusty_v8_*` `http_file` version still +present in `MODULE.bazel` and validate every matching entry. CI runs the check +command to block checksum drift. The consumer-facing selectors are: - `//third_party/v8:rusty_v8_archive_for_target` - `//third_party/v8:rusty_v8_binding_for_target` -Musl release assets are expected at the tag: +Published release assets are expected at the tag: - `rusty-v8-v` @@ -49,13 +61,45 @@ with these raw asset names: - `librusty_v8_release_.a.gz` - `src_binding_release_.rs` -The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. -It builds musl release pairs from source and keeps the release artifacts as the -statically linked form: +During the sandbox rollout, sandbox-enabled assets are published alongside those +current assets on the same tag, with the Rust crate's sandbox feature suffix in +their raw names: +- `librusty_v8_ptrcomp_sandbox_release_.a.gz` +- `src_binding_ptrcomp_sandbox_release_.rs` + +The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. +Tagged runs build release artifacts from the Bazel graph itself: + +- `//third_party/v8:rusty_v8_release_pair_x86_64_apple_darwin` +- `//third_party/v8:rusty_v8_release_pair_aarch64_apple_darwin` +- `//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_gnu` +- `//third_party/v8:rusty_v8_release_pair_aarch64_unknown_linux_gnu` - `//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl` - `//third_party/v8:rusty_v8_release_pair_aarch64_unknown_linux_musl` +The same run also builds the matching sandbox pair targets: + +- `//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin` +- `//third_party/v8:rusty_v8_sandbox_release_pair_aarch64_apple_darwin` +- `//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_gnu` +- `//third_party/v8:rusty_v8_sandbox_release_pair_aarch64_unknown_linux_gnu` +- `//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl` +- `//third_party/v8:rusty_v8_sandbox_release_pair_aarch64_unknown_linux_musl` + +The Bazel graph pins the same libc++, libc++abi, and llvm-libc source revisions +used by `rusty_v8 v147.4.0`, compiles published artifact targets with +`--config=rusty-v8-upstream-libcxx`, and folds the matching runtime objects into +the final static archive so Cargo consumers can link it with the `v8` crate's +default `use_custom_libcxx` feature. The config keeps the object files and the +bundled runtime on Chromium's `std::__Cr` ABI namespace instead of mixing those +objects with the toolchain libc++ default namespace. + +MSVC is not part of the Bazel-produced matrix yet. The repository's current +hermetic Windows C++ platform is `windows-gnullvm`/`x86_64-w64-windows-gnu`, so +it cannot truthfully reproduce upstream's `*-pc-windows-msvc` archives until we +add a real MSVC-targeting C++ toolchain to the Bazel graph. + Cargo musl builds use `RUSTY_V8_ARCHIVE` plus a downloaded `RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release assets directly. We do not use `RUSTY_V8_MIRROR` for musl because the upstream `v8` diff --git a/third_party/v8/libcxx.BUILD.bazel b/third_party/v8/libcxx.BUILD.bazel new file mode 100644 index 0000000000..71a8f8ddce --- /dev/null +++ b/third_party/v8/libcxx.BUILD.bazel @@ -0,0 +1,157 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("@llvm//toolchain/runtimes:cc_runtime_library.bzl", "cc_runtime_stage0_library") + +package(default_visibility = ["//visibility:public"]) + +config_setting( + name = "is_linux", + constraint_values = ["@platforms//os:linux"], +) + +config_setting( + name = "is_windows", + constraint_values = ["@platforms//os:windows"], +) + +LIBCXX_SRCS = [ + "src/algorithm.cpp", + "src/any.cpp", + "src/atomic.cpp", + "src/barrier.cpp", + "src/bind.cpp", + "src/call_once.cpp", + "src/charconv.cpp", + "src/chrono.cpp", + "src/condition_variable.cpp", + "src/condition_variable_destructor.cpp", + "src/error_category.cpp", + "src/exception.cpp", + "src/filesystem/directory_iterator.cpp", + "src/filesystem/filesystem_error.cpp", + "src/filesystem/operations.cpp", + "src/filesystem/path.cpp", + "src/functional.cpp", + "src/future.cpp", + "src/hash.cpp", + "src/ios.cpp", + "src/ios.instantiations.cpp", + "src/iostream.cpp", + "src/locale.cpp", + "src/memory.cpp", + "src/mutex.cpp", + "src/mutex_destructor.cpp", + "src/new.cpp", + "src/new_handler.cpp", + "src/new_helpers.cpp", + "src/optional.cpp", + "src/random.cpp", + "src/random_shuffle.cpp", + "src/regex.cpp", + "src/ryu/d2fixed.cpp", + "src/ryu/d2s.cpp", + "src/ryu/f2s.cpp", + "src/shared_mutex.cpp", + "src/stdexcept.cpp", + "src/string.cpp", + "src/strstream.cpp", + "src/system_error.cpp", + "src/thread.cpp", + "src/typeinfo.cpp", + "src/valarray.cpp", + "src/variant.cpp", + "src/vector.cpp", + "src/verbose_abort.cpp", +] + +cc_library( + name = "headers", + hdrs = glob(["include/**"]), + strip_include_prefix = "include", + deps = ["@//third_party/v8/libcxx_config:headers"], +) + +cc_library( + name = "internal_headers", + hdrs = glob([ + "src/**/*.h", + "src/**/*.ipp", + ]), + includes = ["src"], +) + +cc_runtime_stage0_library( + name = "libcxx", + srcs = LIBCXX_SRCS + select({ + ":is_linux": [ + "src/filesystem/directory_entry.cpp", + "src/filesystem/filesystem_clock.cpp", + ], + "//conditions:default": [], + }) + select({ + ":is_windows": [ + "src/support/win32/locale_win32.cpp", + "src/support/win32/support.cpp", + "src/support/win32/thread_win32.cpp", + ], + "//conditions:default": [], + }), + copts = [ + "-fexceptions", + "-frtti", + "-fstrict-aliasing", + "-fvisibility=hidden", + "-fvisibility-inlines-hidden", + "-nostdinc++", + "-std=c++23", + "-Wno-nullability-completeness", + "-Wno-unused-parameter", + "-Wundef", + ] + select({ + ":is_windows": ["-Wno-macro-redefined"], + "//conditions:default": ["-fPIC"], + }), + defines = [ + "CR_LIBCXX_REVISION=7ab65651aed6802d2599dcb7a73b1f82d5179d05", + "LIBCXX_BUILDING_LIBCXXABI", + "LIBC_NAMESPACE=__llvm_libc_cr", + "_LIBCPP_BUILDING_LIBRARY", + "_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", + "_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", + "_LIBCPP_INSTRUMENTED_WITH_ASAN=0", + "_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", + ] + select({ + "@llvm//platforms/config:musl": [ + # Chromium's checked-in __config_site uses this switch to enable + # libc++'s musl-specific configuration. + "ANDROID_HOST_MUSL", + ], + "//conditions:default": [], + }) + select({ + ":is_windows": [ + "NTDDI_VERSION=NTDDI_WIN7", + "WINVER=_WIN32_WINNT_WIN7", + "_WIN32_WINNT=_WIN32_WINNT_WIN7", + ], + "//conditions:default": [], + }), + includes = ["src"], + implementation_deps = [ + ":headers", + ":internal_headers", + "@rusty_v8_libcxxabi//:headers", + "@rusty_v8_llvm_libc//:headers", + ] + select({ + ":is_linux": [ + "@@llvm++kernel_headers+kernel_headers//:kernel_headers", + ], + "//conditions:default": [], + }) + select({ + "@llvm//platforms/config:gnu": [ + "@@llvm++glibc+glibc//:gnu_libc_headers", + ], + "@llvm//platforms/config:musl": [ + "@@llvm++musl+musl_libc//:musl_libc_headers", + ], + "//conditions:default": [], + }), +) diff --git a/third_party/v8/libcxx_config/BUILD.bazel b/third_party/v8/libcxx_config/BUILD.bazel new file mode 100644 index 0000000000..e4a3ee6a85 --- /dev/null +++ b/third_party/v8/libcxx_config/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "headers", + hdrs = [ + "__assertion_handler", + "__config_site", + ], + includes = ["."], +) diff --git a/third_party/v8/libcxx_config/__assertion_handler b/third_party/v8/libcxx_config/__assertion_handler new file mode 100644 index 0000000000..1d1c2c98f2 --- /dev/null +++ b/third_party/v8/libcxx_config/__assertion_handler @@ -0,0 +1,27 @@ +// -*- C++ -*- + +#ifndef _LIBCPP___ASSERTION_HANDLER +#define _LIBCPP___ASSERTION_HANDLER + +#include <__config> +#include <__verbose_abort> + +#if !defined(_LIBCPP_HAS_NO_PRAGMA_SYSTEM_HEADER) +#pragma GCC system_header +#endif + +#if defined(OFFICIAL_BUILD) && !defined(DCHECK_ALWAYS_ON) + +[[noreturn]] inline _LIBCPP_HIDE_FROM_ABI void __libcpp_hardening_failure() { + __builtin_trap(); +} + +#define _LIBCPP_ASSERTION_HANDLER(message) ((void)message, __libcpp_hardening_failure()) + +#else + +#define _LIBCPP_ASSERTION_HANDLER(message) _LIBCPP_VERBOSE_ABORT("%s", message) + +#endif + +#endif // _LIBCPP___ASSERTION_HANDLER diff --git a/third_party/v8/libcxx_config/__config_site b/third_party/v8/libcxx_config/__config_site new file mode 100644 index 0000000000..1053f9fe45 --- /dev/null +++ b/third_party/v8/libcxx_config/__config_site @@ -0,0 +1,55 @@ +#ifndef _LIBCPP_CONFIG_SITE +#define _LIBCPP_CONFIG_SITE + +#define _LIBCPP_ABI_NAMESPACE __Cr +#define _LIBCPP_ABI_VERSION 2 + +#define _LIBCPP_ABI_FORCE_ITANIUM 0 +#define _LIBCPP_ABI_FORCE_MICROSOFT 0 +#define _LIBCPP_HAS_THREADS 1 +#define _LIBCPP_HAS_MONOTONIC_CLOCK 1 +#define _LIBCPP_HAS_TERMINAL 1 + +#ifdef ANDROID_HOST_MUSL +#define _LIBCPP_HAS_MUSL_LIBC 1 +#else +#define _LIBCPP_HAS_MUSL_LIBC 0 +#endif + +#ifdef _WIN32 +#define _LIBCPP_HAS_THREAD_API_PTHREAD 0 +#define _LIBCPP_HAS_THREAD_API_EXTERNAL 0 +#define _LIBCPP_HAS_THREAD_API_WIN32 1 +#else +#define _LIBCPP_HAS_THREAD_API_PTHREAD 1 +#define _LIBCPP_HAS_THREAD_API_EXTERNAL 0 +#define _LIBCPP_HAS_THREAD_API_WIN32 0 +#endif + +#define _LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS 0 +#define _LIBCPP_HAS_FILESYSTEM 1 +#define _LIBCPP_HAS_RANDOM_DEVICE 1 +#define _LIBCPP_HAS_LOCALIZATION 1 +#define _LIBCPP_HAS_UNICODE 1 +#define _LIBCPP_HAS_WIDE_CHARACTERS 1 +#define _LIBCPP_HAS_TIME_ZONE_DATABASE 1 + +#if defined(__APPLE__) +#define _LIBCPP_PSTL_BACKEND_LIBDISPATCH +#else +#define _LIBCPP_PSTL_BACKEND_STD_THREAD +#endif + +#define _LIBCPP_ASSERTION_SEMANTIC_DEFAULT \ + _LIBCPP_ASSERTION_SEMANTIC_HARDENING_DEPENDENT + +#define _LIBCPP_LIBC_PICOLIBC 0 +#define _LIBCPP_LIBC_NEWLIB 0 + +#define _LIBCPP_NO_AUTO_LINK +#define _LIBCPP_REMOVE_TRANSITIVE_INCLUDES +#define _LIBCPP_NO_ABI_TAG +#define _LIBCPP_VERBOSE_ABORT(...) ::std::__libcpp_verbose_abort(__VA_ARGS__) +#define _LIBCPP_HAS_NO_INCOMPLETE_PSTL + +#endif // _LIBCPP_CONFIG_SITE diff --git a/third_party/v8/libcxxabi.BUILD.bazel b/third_party/v8/libcxxabi.BUILD.bazel new file mode 100644 index 0000000000..0645511db7 --- /dev/null +++ b/third_party/v8/libcxxabi.BUILD.bazel @@ -0,0 +1,98 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("@llvm//toolchain/runtimes:cc_runtime_library.bzl", "cc_runtime_stage0_library") + +package(default_visibility = ["//visibility:public"]) + +config_setting( + name = "is_linux", + constraint_values = ["@platforms//os:linux"], +) + +config_setting( + name = "is_windows", + constraint_values = ["@platforms//os:windows"], +) + +cc_library( + name = "headers", + hdrs = glob(["include/**"]), + strip_include_prefix = "include", +) + +cc_runtime_stage0_library( + name = "libcxxabi", + srcs = [ + "src/abort_message.cpp", + "src/cxa_aux_runtime.cpp", + "src/cxa_default_handlers.cpp", + "src/cxa_demangle.cpp", + "src/cxa_exception.cpp", + "src/cxa_exception_storage.cpp", + "src/cxa_guard.cpp", + "src/cxa_handlers.cpp", + "src/cxa_personality.cpp", + "src/cxa_vector.cpp", + "src/cxa_virtual.cpp", + "src/fallback_malloc.cpp", + "src/private_typeinfo.cpp", + "src/stdlib_exception.cpp", + "src/stdlib_stdexcept.cpp", + "src/stdlib_typeinfo.cpp", + ] + select({ + ":is_linux": ["src/cxa_thread_atexit.cpp"], + "//conditions:default": [], + }), + textual_hdrs = glob([ + "src/**/*.def", + "src/**/*.h", + "src/**/*.inc", + ]), + copts = [ + "-fexceptions", + "-frtti", + "-fstrict-aliasing", + "-fvisibility=hidden", + "-fvisibility-inlines-hidden", + "-nostdinc++", + "-std=c++23", + "-Wno-nullability-completeness", + "-Wno-unused-parameter", + "-Wundef", + ] + select({ + ":is_windows": ["-Wno-macro-redefined"], + "//conditions:default": ["-fPIC"], + }), + defines = [ + "LIBCXXABI_SILENT_TERMINATE", + "_LIBCPP_BUILDING_LIBRARY", + "_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", + "_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", + "_LIBCPP_INSTRUMENTED_WITH_ASAN=0", + "_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", + ] + select({ + "@llvm//platforms/config:musl": [ + "ANDROID_HOST_MUSL", + ], + "//conditions:default": [], + }), + includes = ["src"], + implementation_deps = [ + ":headers", + "@rusty_v8_libcxx//:headers", + "@rusty_v8_libcxx//:internal_headers", + "@//third_party/v8/libcxx_config:headers", + ] + select({ + ":is_linux": [ + "@@llvm++kernel_headers+kernel_headers//:kernel_headers", + ], + "//conditions:default": [], + }) + select({ + "@llvm//platforms/config:gnu": [ + "@@llvm++glibc+glibc//:gnu_libc_headers", + ], + "@llvm//platforms/config:musl": [ + "@@llvm++musl+musl_libc//:musl_libc_headers", + ], + "//conditions:default": [], + }), +) diff --git a/third_party/v8/llvm_libc.BUILD.bazel b/third_party/v8/llvm_libc.BUILD.bazel new file mode 100644 index 0000000000..7940656fb8 --- /dev/null +++ b/third_party/v8/llvm_libc.BUILD.bazel @@ -0,0 +1,10 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "headers", + hdrs = glob(["**/*.h"]), + defines = ["LIBC_NAMESPACE=__llvm_libc_cr"], + includes = ["."], +) diff --git a/third_party/v8/rusty_v8_146_4_0.sha256 b/third_party/v8/rusty_v8_146_4_0.sha256 deleted file mode 100644 index 09b3e1b0b2..0000000000 --- a/third_party/v8/rusty_v8_146_4_0.sha256 +++ /dev/null @@ -1,10 +0,0 @@ -bfe2c9be32a56c28546f0f965825ee68fbf606405f310cc4e17b448a568cf98a librusty_v8_release_aarch64-apple-darwin.a.gz -dbf165b07c81bdb054bc046b43d23e69fcf7bcc1a4c1b5b4776983a71062ecd8 librusty_v8_release_aarch64-unknown-linux-gnu.a.gz -ed13363659c6d08583ac8fdc40493445c5767d8b94955a4d5d7bb8d5a81f6bf8 rusty_v8_release_aarch64-pc-windows-msvc.lib.gz -630cd240f1bbecdb071417dc18387ab81cf67c549c1c515a0b4fcf9eba647bb7 librusty_v8_release_x86_64-apple-darwin.a.gz -e64b4d99e4ae293a2e846244a89b80178ba10382c13fb591c1fa6968f5291153 librusty_v8_release_x86_64-unknown-linux-gnu.a.gz -90a9a2346acd3685a355e98df85c24dbe406cb124367d16259a4b5d522621862 rusty_v8_release_x86_64-pc-windows-msvc.lib.gz -27a08ed26c34297bfd93e514692ccc44b85f8b15c6aa39cf34e784f84fb37e8e librusty_v8_release_aarch64-unknown-linux-musl.a.gz -09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a src_binding_release_aarch64-unknown-linux-musl.rs -20d8271ad712323d352c1383c36e3c4b755abc41ece35819c49c75ec7134d2f8 librusty_v8_release_x86_64-unknown-linux-musl.a.gz -09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a src_binding_release_x86_64-unknown-linux-musl.rs diff --git a/third_party/v8/rusty_v8_147_4_0.sha256 b/third_party/v8/rusty_v8_147_4_0.sha256 new file mode 100644 index 0000000000..a287e5fd5b --- /dev/null +++ b/third_party/v8/rusty_v8_147_4_0.sha256 @@ -0,0 +1,2 @@ +1fa3f94d9e09cff1f6bcce94c478e5cb072c0755f6a0357abadb9dd3b48d8127 rusty_v8_release_aarch64-pc-windows-msvc.lib.gz +e2827ff98b1a9d4c0343000fc5124ac30dfab3007bc0129c168c9355fc2fcd7c rusty_v8_release_x86_64-pc-windows-msvc.lib.gz diff --git a/third_party/v8/v8_crate.BUILD.bazel b/third_party/v8/v8_crate.BUILD.bazel index f9b2a1998c..3988bc0345 100644 --- a/third_party/v8/v8_crate.BUILD.bazel +++ b/third_party/v8/v8_crate.BUILD.bazel @@ -5,6 +5,11 @@ filegroup( srcs = ["src/binding.cc"], ) +filegroup( + name = "crdtp_binding_cc", + srcs = ["src/crdtp_binding.cc"], +) + filegroup( name = "support_h", srcs = ["src/support.h"], From 1dd9bf9a74d66729d0e4169f57df5bc5dc762e77 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 18 May 2026 21:33:46 -0700 Subject: [PATCH 097/114] Remove explicit connector tool undeferral (#23390) ## Summary - remove the explicit-connector carveout that kept mentioned app tools directly exposed instead of deferred - keep the surviving explicit-mention reconstruction only for analytics, preserving `codex_app_mentioned` and `codex_app_used.invoke_type` - trim the now-unused prompt/tool-exposure plumbing and refresh coverage around always-defer behavior ## Verification - `just fmt` - `cargo test -p codex-analytics` - `cargo test -p codex-core` *(one transient timeout in `shell_snapshot::tests::macos_zsh_snapshot_includes_sections`; isolated rerun passed)* - `cargo test -p codex-core --lib shell_snapshot::tests::macos_zsh_snapshot_includes_sections` - `cargo test -p codex-core --test all explicit_app_mentions_respect_always_defer` - `cargo test -p codex-core --lib mcp_tool_exposure::tests::always_defer_feature_defers_apps_too` - `just fix -p codex-analytics` - `just fix -p codex-core` --- codex-rs/core/src/compact_remote.rs | 4 - codex-rs/core/src/compact_remote_v2.rs | 4 - codex-rs/core/src/mcp_tool_exposure.rs | 11 +- codex-rs/core/src/mcp_tool_exposure_test.rs | 83 ++------------ codex-rs/core/src/prompt_debug.rs | 11 +- codex-rs/core/src/session/mod.rs | 2 - codex-rs/core/src/session/tests.rs | 68 ----------- codex-rs/core/src/session/turn.rs | 114 +------------------ codex-rs/core/src/session_startup_prewarm.rs | 4 - codex-rs/core/tests/suite/search_tool.rs | 22 +++- 10 files changed, 26 insertions(+), 297 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index bc684647eb..c7ba1a314f 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; @@ -174,9 +173,6 @@ async fn run_remote_compact_task_inner_impl( let tool_router = built_tools( sess.as_ref(), turn_context.as_ref(), - &prompt_input, - &HashSet::new(), - /*skills_outcome*/ None, &CancellationToken::new(), ) .await?; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 3f5368a960..2e261b2e94 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; @@ -180,9 +179,6 @@ async fn run_remote_compact_task_inner_impl( let tool_router = built_tools( sess.as_ref(), turn_context.as_ref(), - &prompt_input, - &HashSet::new(), - /*skills_outcome*/ None, &CancellationToken::new(), ) .await?; diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index 0bf696acdf..8f8be11072 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -18,7 +18,6 @@ pub(crate) struct McpToolExposure { pub(crate) fn build_mcp_tool_exposure( all_mcp_tools: &[McpToolInfo], connectors: Option<&[connectors::AppInfo]>, - explicitly_enabled_connectors: &[connectors::AppInfo], config: &Config, tools_config: &ToolsConfig, ) -> McpToolExposure { @@ -44,16 +43,8 @@ pub(crate) fn build_mcp_tool_exposure( }; } - let direct_tools = - filter_codex_apps_mcp_tools(all_mcp_tools, explicitly_enabled_connectors, config); - let direct_tool_names = direct_tools - .iter() - .map(McpToolInfo::canonical_tool_name) - .collect::>(); - deferred_tools.retain(|tool| !direct_tool_names.contains(&tool.canonical_tool_name())); - McpToolExposure { - direct_tools, + direct_tools: Vec::new(), deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools), } } diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index f9468d898f..06c9d12559 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -120,13 +120,8 @@ async fn directly_exposes_small_effective_tool_sets() { let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); - let exposure = build_mcp_tool_exposure( - &mcp_tools, - /*connectors*/ None, - &[], - &config, - &tools_config, - ); + let exposure = + build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools)); assert!(exposure.deferred_tools.is_none()); @@ -138,13 +133,8 @@ async fn searches_large_effective_tool_sets() { let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); - let exposure = build_mcp_tool_exposure( - &mcp_tools, - /*connectors*/ None, - &[], - &config, - &tools_config, - ); + let exposure = + build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); assert!(exposure.direct_tools.is_empty()); let deferred_tools = exposure @@ -155,58 +145,7 @@ async fn searches_large_effective_tool_sets() { } #[tokio::test] -async fn directly_exposes_explicit_apps_without_deferred_overlap() { - let config = test_config().await; - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; - let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); - mcp_tools.push(make_mcp_tool( - CODEX_APPS_MCP_SERVER_NAME, - "calendar_create_event", - "mcp__codex_apps__calendar", - "_create_event", - Some("calendar"), - Some("Calendar"), - )); - let connectors = vec![make_connector("calendar", "Calendar")]; - - let exposure = build_mcp_tool_exposure( - &mcp_tools, - Some(connectors.as_slice()), - connectors.as_slice(), - &config, - &tools_config, - ); - - let direct_tool_names = tool_names(&exposure.direct_tools); - assert_eq!( - direct_tool_names, - HashSet::from([ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event" - )]) - ); - assert_eq!( - exposure.deferred_tools.as_ref().map(Vec::len), - Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1) - ); - let deferred_tools = exposure - .deferred_tools - .as_ref() - .expect("large tool sets should be discoverable through tool_search"); - let deferred_tool_names = tool_names(deferred_tools); - assert!( - direct_tool_names.is_disjoint(&deferred_tool_names), - "direct tools should not also be deferred: {direct_tool_names:?}" - ); - assert!(!deferred_tool_names.contains(&ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event" - ))); - assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool_0"))); -} - -#[tokio::test] -async fn always_defer_feature_preserves_explicit_apps() { +async fn always_defer_feature_defers_apps_too() { let mut config = test_config().await; config .features @@ -236,26 +175,18 @@ async fn always_defer_feature_preserves_explicit_apps() { let exposure = build_mcp_tool_exposure( &mcp_tools, Some(connectors.as_slice()), - connectors.as_slice(), &config, &tools_config, ); - let direct_tool_names = tool_names(&exposure.direct_tools); - assert_eq!( - direct_tool_names, - HashSet::from([ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event" - )]) - ); + assert!(exposure.direct_tools.is_empty()); let deferred_tools = exposure .deferred_tools .as_ref() .expect("MCP tools should be discoverable through tool_search"); let deferred_tool_names = tool_names(deferred_tools); assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool"))); - assert!(!deferred_tool_names.contains(&ToolName::namespaced( + assert!(deferred_tool_names.contains(&ToolName::namespaced( "mcp__codex_apps__calendar", "_create_event" ))); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 72fa5b4e44..c6386d7c77 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use codex_exec_server::EnvironmentManager; @@ -86,15 +85,7 @@ pub(crate) async fn build_prompt_input_from_session( .clone_history() .await .for_prompt(&turn_context.model_info.input_modalities); - let router = built_tools( - sess, - turn_context.as_ref(), - &prompt_input, - &HashSet::new(), - Some(turn_context.turn_skills.outcome.as_ref()), - &CancellationToken::new(), - ) - .await?; + let router = built_tools(sess, turn_context.as_ref(), &CancellationToken::new()).await?; let base_instructions = sess.get_base_instructions().await; let prompt = build_prompt( prompt_input, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cb848286c9..99c2f39b69 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -215,8 +215,6 @@ pub(crate) use self::session::SessionSettingsUpdate; use self::turn::AssistantMessageStreamParsers; #[cfg(test)] use self::turn::collect_explicit_app_ids_from_skill_items; -#[cfg(test)] -use self::turn::filter_connectors_for_input; use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 2363f02cc6..37b46b6dae 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1497,74 +1497,6 @@ disabled_tools = [ ); } -#[test] -fn filter_connectors_for_input_skips_duplicate_slug_mentions() { - let connectors = vec![ - make_connector("one", "Foo Bar"), - make_connector("two", "Foo-Bar"), - ]; - let input = vec![user_message("use $foo-bar")]; - let explicitly_enabled_connectors = HashSet::new(); - let skill_name_counts_lower = HashMap::new(); - - let selected = filter_connectors_for_input( - &connectors, - &input, - &explicitly_enabled_connectors, - &skill_name_counts_lower, - ); - - assert_eq!(selected, Vec::new()); -} - -#[test] -fn filter_connectors_for_input_skips_when_skill_name_conflicts() { - let connectors = vec![make_connector("one", "Todoist")]; - let input = vec![user_message("use $todoist")]; - let explicitly_enabled_connectors = HashSet::new(); - let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]); - - let selected = filter_connectors_for_input( - &connectors, - &input, - &explicitly_enabled_connectors, - &skill_name_counts_lower, - ); - - assert_eq!(selected, Vec::new()); -} - -#[test] -fn filter_connectors_for_input_skips_disabled_connectors() { - let mut connector = make_connector("calendar", "Calendar"); - connector.is_enabled = false; - let input = vec![user_message("use $calendar")]; - let explicitly_enabled_connectors = HashSet::new(); - let selected = filter_connectors_for_input( - &[connector], - &input, - &explicitly_enabled_connectors, - &HashMap::new(), - ); - - assert_eq!(selected, Vec::new()); -} - -#[test] -fn filter_connectors_for_input_skips_plugin_mentions() { - let connectors = vec![make_connector("figma", "Figma")]; - let input = vec![user_message("use [@figma](plugin://figma@openai-curated)")]; - let explicitly_enabled_connectors = HashSet::new(); - let selected = filter_connectors_for_input( - &connectors, - &input, - &explicitly_enabled_connectors, - &HashMap::new(), - ); - - assert_eq!(selected, Vec::new()); -} - #[test] fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { let connectors = vec![make_connector("calendar", "Calendar")]; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 24788d4d91..27d35504f9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -4,14 +4,12 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use crate::SkillInjections; -use crate::SkillLoadOutcome; use crate::build_skill_injections; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::collect_explicit_skill_mentions; use crate::compact::InitialContextInjection; -use crate::compact::collect_user_messages; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -221,7 +219,6 @@ pub(crate) async fn run_turn( track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; - let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); let mut last_agent_message: Option = None; let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains @@ -331,8 +328,6 @@ pub(crate) async fn run_turn( &mut client_session, turn_metadata_header.as_deref(), sampling_request_input, - &explicitly_enabled_connectors, - skills_outcome, cancellation_token.child_token(), ) .await @@ -918,82 +913,6 @@ pub(super) fn collect_explicit_app_ids_from_skill_items( connector_ids } -pub(super) fn filter_connectors_for_input( - connectors: &[connectors::AppInfo], - input: &[ResponseItem], - explicitly_enabled_connectors: &HashSet, - skill_name_counts_lower: &HashMap, -) -> Vec { - let connectors: Vec = connectors - .iter() - .filter(|connector| connector.is_enabled) - .cloned() - .collect::>(); - if connectors.is_empty() { - return Vec::new(); - } - - let user_messages = collect_user_messages(input); - if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() { - return Vec::new(); - } - - let mentions = collect_tool_mentions_from_messages(&user_messages); - let mention_names_lower = mentions - .plain_names - .iter() - .map(|name| name.to_ascii_lowercase()) - .collect::>(); - - let connector_slug_counts = build_connector_slug_counts(&connectors); - let mut allowed_connector_ids = explicitly_enabled_connectors.clone(); - for path in mentions - .paths - .iter() - .filter(|path| tool_kind_for_path(path) == ToolMentionKind::App) - { - if let Some(connector_id) = app_id_from_path(path) { - allowed_connector_ids.insert(connector_id.to_string()); - } - } - - connectors - .into_iter() - .filter(|connector| { - connector_inserted_in_messages( - connector, - &mention_names_lower, - &allowed_connector_ids, - &connector_slug_counts, - skill_name_counts_lower, - ) - }) - .collect() -} - -fn connector_inserted_in_messages( - connector: &connectors::AppInfo, - mention_names_lower: &HashSet, - allowed_connector_ids: &HashSet, - connector_slug_counts: &HashMap, - skill_name_counts_lower: &HashMap, -) -> bool { - if allowed_connector_ids.contains(&connector.id) { - return true; - } - - let mention_slug = codex_connectors::metadata::connector_mention_slug(connector); - let connector_count = connector_slug_counts - .get(&mention_slug) - .copied() - .unwrap_or(0); - let skill_count = skill_name_counts_lower - .get(&mention_slug) - .copied() - .unwrap_or(0); - connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug) -} - pub(crate) fn build_prompt( input: Vec, router: &ToolRouter, @@ -1031,19 +950,9 @@ async fn run_sampling_request( client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, input: Vec, - explicitly_enabled_connectors: &HashSet, - skills_outcome: Option<&SkillLoadOutcome>, cancellation_token: CancellationToken, ) -> CodexResult { - let router = built_tools( - sess.as_ref(), - turn_context.as_ref(), - &input, - explicitly_enabled_connectors, - skills_outcome, - &cancellation_token, - ) - .await?; + let router = built_tools(sess.as_ref(), turn_context.as_ref(), &cancellation_token).await?; let base_instructions = sess.get_base_instructions().await; @@ -1173,9 +1082,6 @@ async fn run_sampling_request( pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, - input: &[ResponseItem], - explicitly_enabled_connectors: &HashSet, - skills_outcome: Option<&SkillLoadOutcome>, cancellation_token: &CancellationToken, ) -> CodexResult> { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; @@ -1191,9 +1097,6 @@ pub(crate) async fn built_tools( .plugins_for_config(&turn_context.config.plugins_config_input()) .await; - let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone(); - effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); - let apps_enabled = turn_context.apps_enabled(); let accessible_connectors = apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&all_mcp_tools)); @@ -1245,24 +1148,9 @@ pub(crate) async fn built_tools( None }; - let explicitly_enabled = if let Some(connectors) = connectors.as_ref() { - let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { - build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 - }); - - filter_connectors_for_input( - connectors, - input, - &effective_explicitly_enabled_connectors, - &skill_name_counts_lower, - ) - } else { - Vec::new() - }; let mcp_tool_exposure = build_mcp_tool_exposure( &all_mcp_tools, connectors.as_deref(), - explicitly_enabled.as_slice(), &turn_context.config, &turn_context.tools_config, ); diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0fac4b1c9a..ca21e3ea72 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -235,9 +234,6 @@ async fn schedule_startup_prewarm_inner( let startup_router = built_tools( session.as_ref(), startup_turn_context.as_ref(), - &[], - &HashSet::new(), - /*skills_outcome*/ None, &startup_cancellation_token, ) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 51f100d3c5..8a092d7605 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -335,7 +335,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> { +async fn explicit_app_mentions_respect_always_defer() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -350,7 +350,13 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> ) .await; - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); + let mut builder = + configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + }); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -362,18 +368,22 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()> let body = mock.single_request().body_json(); let tools = tool_names(&body); + assert!( + tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), + "explicit app mentions should leave app tools deferred when always-defer is active: {tools:?}" + ); assert!( namespace_child_tool( &body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL ) - .is_some(), - "expected explicit app mention to expose create tool, got tools: {tools:?}" + .is_none(), + "explicit app mentions should not directly expose create tool, got tools: {tools:?}" ); assert!( - namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_some(), - "expected explicit app mention to expose list tool, got tools: {tools:?}" + namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_none(), + "explicit app mentions should not directly expose list tool, got tools: {tools:?}" ); Ok(()) From 3009e23644c290bc524c794380a5def889b57266 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 18 May 2026 23:26:17 -0700 Subject: [PATCH 098/114] core: expose permission profile picker metadata (#22928) ## Why The `/permissions` picker needs a config-level way to distinguish legacy anonymous presets from named permission-profile mode. That signal cannot be inferred reliably in the TUI, especially for the edge case where `default_permissions = ":workspace"` is present without a `[permissions]` table. ## What changed - Expose whether the merged config is explicitly in permission-profile mode. - Expose the configured custom permission profile IDs alongside the built-in profile semantics. - Add regression coverage for profile mode detection and custom profile metadata, including the `default_permissions = ":workspace"` case. - Update the thread-manager sample config literal to match the expanded config shape. ## Stack 1. **This PR**: config metadata needed by downstream permission-profile consumers. 2. [#22931](https://github.com/openai/codex/pull/22931): refresh active permission profiles through runtime/session/network state. 3. [#21559](https://github.com/openai/codex/pull/21559): switch `/permissions` to the profile-aware TUI picker. ## Verification - `cargo check -p codex-thread-manager-sample` - `cargo test -p codex-core default_permissions_can_select_builtin_profile_without_permissions_table` - `cargo test -p codex-core permissions_profiles_allow_direct_write_roots_outside_workspace_root` --- codex-rs/core/src/config/config_tests.rs | 14 ++++++++++++++ codex-rs/core/src/config/mod.rs | 20 ++++++++++++++++++++ codex-rs/thread-manager-sample/src/main.rs | 2 ++ 3 files changed, 36 insertions(+) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 50b03b5051..06ee405495 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1822,6 +1822,8 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl ) .await?; + assert!(config.explicit_permission_profile_mode); + assert!(config.custom_permission_profile_ids.is_empty()); let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config @@ -2377,6 +2379,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() ) .await?; + assert_eq!( + config.custom_permission_profile_ids, + vec!["workspace".to_string()] + ); let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( codex_home.path().join("memories"), )?)?; @@ -7721,6 +7727,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { windows_sandbox_mode: None, windows_sandbox_private_desktop: true, }, + explicit_permission_profile_mode: false, + custom_permission_profile_ids: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, @@ -8172,6 +8180,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { windows_sandbox_mode: None, windows_sandbox_private_desktop: true, }, + explicit_permission_profile_mode: false, + custom_permission_profile_ids: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, @@ -8337,6 +8347,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { windows_sandbox_mode: None, windows_sandbox_private_desktop: true, }, + explicit_permission_profile_mode: false, + custom_permission_profile_ids: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, @@ -8487,6 +8499,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { windows_sandbox_mode: None, windows_sandbox_private_desktop: true, }, + explicit_permission_profile_mode: false, + custom_permission_profile_ids: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 583c2a42b6..74fdb8383c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -539,6 +539,13 @@ pub struct Config { /// Effective permission configuration for shell tool execution. pub permissions: Permissions, + /// Whether config explicitly selected named permissions profiles instead + /// of the legacy `sandbox_mode` syntax. + pub explicit_permission_profile_mode: bool, + + /// User-defined permission profile IDs available from effective config. + pub custom_permission_profile_ids: Vec, + /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as /// ARC. @@ -2604,6 +2611,17 @@ impl Config { Some(PermissionConfigSyntax::Profiles) ) || permission_config_syntax.is_none(); + let explicit_permission_profile_mode = default_permissions_override.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ); + let custom_permission_profile_ids = cfg + .permissions + .as_ref() + .map_or_else(Vec::new, |permissions| { + permissions.entries.keys().cloned().collect() + }); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); let should_seed_legacy_workspace_roots = default_permissions.is_none() @@ -3371,6 +3389,8 @@ impl Config { windows_sandbox_mode, windows_sandbox_private_desktop, }, + explicit_permission_profile_mode, + custom_permission_profile_ids, approvals_reviewer: constrained_approvals_reviewer.value(), enforce_residency: enforce_residency.value, notify: cfg.notify, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 8593b8a477..3410711e3f 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -175,6 +175,8 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R Constrained::allow_any(AskForApproval::Never), Constrained::allow_any(PermissionProfile::read_only()), )?, + explicit_permission_profile_mode: false, + custom_permission_profile_ids: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, From 826b2182ed32b8534df1c44c2a82d8eeca094b58 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 10:34:24 +0200 Subject: [PATCH 099/114] Preserve context baselines for full-history agent forks (#23352) ## Why Full-history agent forks should continue from the same prompt prefix as the parent. Dropping the stored `TurnContext` baseline forced the child to rebuild startup context on its first turn, which can duplicate developer instructions and also loses the cache continuity that a full-history fork is supposed to preserve. Truncated forks are different: once we keep only the last N turns, the original prompt prefix is no longer intact, so the child must establish a fresh context baseline. ## What changed - Preserve `RolloutItem::TurnContext` when forking with `SpawnAgentForkMode::FullHistory`, and keep dropping it for truncated forks: https://github.com/openai/codex/blob/4090717d94c1fc7f33c9bd122be133a0c5752052/codex-rs/core/src/agent/control.rs#L98-L126 and https://github.com/openai/codex/blob/4090717d94c1fc7f33c9bd122be133a0c5752052/codex-rs/core/src/agent/control.rs#L399-L401 - Remove the special-case MultiAgentV2 usage-hint filtering path. Full-history fork now preserves the cached developer prefix instead of trying to reconstruct part of it. - Extend the fork coverage to assert both sides of the contract: full-history forks keep the parent reference baseline, while last-N forks rebuild context after truncation: https://github.com/openai/codex/blob/4090717d94c1fc7f33c9bd122be133a0c5752052/codex-rs/core/src/agent/control_tests.rs#L603-L759 and https://github.com/openai/codex/blob/4090717d94c1fc7f33c9bd122be133a0c5752052/codex-rs/core/src/agent/control_tests.rs#L854-L977 ## Verification - `cargo test -p codex-core spawn_agent_can_fork_parent_thread_history_with_sanitized_items -- --nocapture` - `RUST_MIN_STACK=16777216 cargo test -p codex-core spawn_agent_fork_last_n_turns_keeps_only_recent_turns -- --nocapture` --- codex-rs/core/src/agent/control.rs | 95 +++++++-- codex-rs/core/src/agent/control_tests.rs | 261 ++++++++++++++++++++++- codex-rs/core/src/session/mod.rs | 16 -- codex-rs/core/src/session/tests.rs | 35 --- 4 files changed, 332 insertions(+), 75 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a246f0084f..42981a5d0a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -96,7 +96,7 @@ fn agent_nickname_candidates( .collect() } -fn keep_forked_rollout_item(item: &RolloutItem) -> bool { +fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { match item { RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() { @@ -120,13 +120,30 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { | ResponseItem::ContextCompaction { .. } | ResponseItem::Other, ) => false, - // A forked child gets its own runtime config, including spawned-agent - // instructions, so it must establish a fresh context diff baseline. - RolloutItem::TurnContext(_) => false, + // Full-history forks preserve the cached prompt prefix and can keep diffing + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) => preserve_reference_context_item, RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, } } +fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "developer" { + return false; + } + let [ContentItem::InputText { text }] = content.as_slice() else { + return false; + }; + + usage_hint_texts + .iter() + .any(|usage_hint_text| usage_hint_text == text) +} + /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -396,16 +413,26 @@ impl AgentControl { forked_rollout_items = truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); } - // MultiAgentV2 root/subagent usage hints are injected as standalone developer - // messages at thread start. When forking history, drop hints from the parent - // so the child gets a fresh hint that matches its own session source/config. let multi_agent_v2_usage_hint_texts_to_filter: Vec = if let Some(parent_thread) = parent_thread.as_ref() { - parent_thread - .codex - .session - .configured_multi_agent_v2_usage_hint_texts() - .await + if parent_thread.enabled(Feature::MultiAgentV2) { + let parent_config = parent_thread.codex.session.get_config().await; + [ + parent_config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone(), + parent_config + .multi_agent_v2 + .subagent_usage_hint_text + .clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + } } else if config.features.enabled(Feature::MultiAgentV2) { [ config.multi_agent_v2.root_agent_usage_hint_text.clone(), @@ -417,19 +444,41 @@ impl AgentControl { } else { Vec::new() }; + let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); forked_rollout_items.retain(|item| { - if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item - && role == "developer" - && let [ContentItem::InputText { text }] = content.as_slice() - && multi_agent_v2_usage_hint_texts_to_filter - .iter() - .any(|usage_hint_text| usage_hint_text == text) - { - return false; - } - - keep_forked_rollout_item(item) + keep_forked_rollout_item(item, preserve_reference_context_item) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + ) }); + for item in &mut forked_rollout_items { + if let RolloutItem::Compacted(compacted) = item + && let Some(replacement_history) = compacted.replacement_history.as_mut() + { + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + }); + } + } + if preserve_reference_context_item + && config.features.enabled(Feature::MultiAgentV2) + && let Some(subagent_usage_hint_text) = + config.multi_agent_v2.subagent_usage_hint_text.clone() + && let Some(subagent_usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_usage_hint_text, + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); + } state .fork_thread_with_source( diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b9ce39887a..7ff50ffef8 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InterAgentCommunication; @@ -677,6 +678,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ], ) .await; + let parent_reference_context_item = turn_context.to_turn_context_item(); + parent_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext( + parent_reference_context_item.clone(), + )]) + .await; parent_thread .codex .session @@ -728,11 +737,26 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { phase: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Child subagent guidance.".to_string(), + }], + phase: None, + }, ]; assert_eq!( history.raw_items(), &expected_history, - "forked child history should keep only parent user messages and assistant final answers" + "full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter" + ); + assert_eq!( + serde_json::to_value(child_thread.codex.session.reference_context_item().await) + .expect("serialize child reference context item"), + serde_json::to_value(Some(parent_reference_context_item)) + .expect("serialize expected reference context item"), + "full-history forked child should preserve the parent diff baseline" ); let expected = ( @@ -766,6 +790,124 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("parent shutdown should submit"); } +#[tokio::test] +async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + parent_config.multi_agent_v2.subagent_usage_hint_text = + Some("Parent subagent guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Child root guidance.".to_string()); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string(); + let replacement_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "compacted parent summary".to_string(), + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + ]; + parent_thread + .codex + .session + .persist_rollout_items(&[ + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(replacement_history), + }), + RolloutItem::TurnContext(turn_context.to_turn_context_item()), + RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)), + ]) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should sanitize compacted usage hints") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "compacted parent summary"), + "forked child history should retain compacted non-hint content" + ); + assert!( + !history_contains_text(history.raw_items(), "Parent root guidance."), + "forked child history should strip stale parent hints from compacted replacement history" + ); + assert!( + history_contains_text(history.raw_items(), "Child subagent guidance."), + "full-history forked child should add the child subagent hint after compacted-history sanitization" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + #[tokio::test] async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { let harness = AgentControlHarness::new().await; @@ -882,6 +1024,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { &[spawn_agent_call(&parent_spawn_call_id)], ) .await; + parent_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext( + spawn_turn_context.to_turn_context_item(), + )]) + .await; parent_thread .codex .session @@ -939,6 +1088,116 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { history_contains_text(history.raw_items(), "current parent task"), "forked child history should keep the parent user message from the requested last-N turn window" ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "last-N forked child should rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + parent_thread + .inject_user_message_without_turn("parent task".to_string()) + .await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + turn_context.as_ref(), + &[ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + spawn_agent_call(&parent_spawn_call_id), + ], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should sanitize parent usage hints") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "Parent root guidance."), + "bounded fork should strip stale parent root hints before the child rebuilds startup context" + ); let _ = harness .control diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 99c2f39b69..75f41ed49e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -884,22 +884,6 @@ impl Session { } } - pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec { - if !self.features.enabled(Feature::MultiAgentV2) { - return Vec::new(); - } - - let state = self.state.lock().await; - let config = &state.session_configuration.original_config_do_not_use; - [ - config.multi_agent_v2.root_agent_usage_hint_text.clone(), - config.multi_agent_v2.subagent_usage_hint_text.clone(), - ] - .into_iter() - .flatten() - .collect() - } - fn managed_network_proxy_active_for_permission_profile( permission_profile: &PermissionProfile, ) -> bool { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 37b46b6dae..9d10f7b90b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -22,7 +22,6 @@ use codex_config::loader::project_trust_key; use codex_config::types::ToolSuggestDisabledTool; use codex_features::Feature; -use codex_features::Features; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::bundled_models_response; @@ -6745,40 +6744,6 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis ); } -#[tokio::test] -async fn configured_multi_agent_v2_usage_hint_texts_use_effective_enabled_feature_state() { - let (mut session, _turn_context) = - make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await; - let mut effective_features = Features::with_defaults(); - effective_features.enable(Feature::MultiAgentV2); - Arc::get_mut(&mut session) - .expect("session should not be shared") - .features = effective_features.into(); - - let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await; - - assert_eq!( - hint_texts, - vec![ - "Root guidance.".to_string(), - "Subagent guidance.".to_string() - ] - ); -} - -#[tokio::test] -async fn configured_multi_agent_v2_usage_hint_texts_omit_effectively_disabled_feature() { - let (mut session, _turn_context) = - make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; - Arc::get_mut(&mut session) - .expect("session should not be shared") - .features = Features::with_defaults().into(); - - let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await; - - assert_eq!(hint_texts, Vec::::new()); -} - #[tokio::test] async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; From ba57aab13aea0731cbb13789f38db0fc60ee25ac Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 11:11:41 +0200 Subject: [PATCH 100/114] feat: dedicated goal DB (#23300) ## Why Thread goals are moving toward extension-owned runtime behavior, but their persisted state was still stored in the shared state database. This makes the goal store harder to isolate and keeps future storage splits tied to ad hoc runtime plumbing. This PR gives goals their own SQLite database while keeping the existing `StateRuntime` entry point. The goal is to make this the pattern for adding more dedicated runtime databases later. This also reduce load on existing DB and reduce contention ## Limitation Thread preview from goal is not supported anymore. I'm looking into this [EDIT]: solved ## What changed - Added a dedicated `goals_1.sqlite` database with its own `goals_migrations` directory. - Moved `thread_goals` creation into the goals DB migration set. - Dropped the old `thread_goals` table from the main state DB with a normal state migration. There is intentionally no backfill for existing goal rows. - Changed `GoalStore` to be backed only by the goals DB pool. - Removed the old goal-write side effect that filled empty `threads.preview` values from the goal objective. - Added shared runtime DB path metadata so startup, telemetry, `codex doctor`, and repair handling can include future DBs without bespoke path lists. - Updated Bazel compile data so the new goals migration directory is available to `sqlx::migrate!`. ## Verification - `cargo check --tests -p codex-state -p codex-cli -p codex-core -p codex-app-server` - `just fix -p codex-state` - `just fix -p codex-cli` - `just fix -p codex-app-server` --- codex-rs/app-server/src/lib.rs | 5 +- .../thread_goal_processor.rs | 8 ++ .../tests/suite/v2/thread_resume.rs | 12 +- codex-rs/cli/src/doctor.rs | 10 +- codex-rs/cli/src/doctor/output.rs | 12 +- codex-rs/cli/src/doctor/output/detail.rs | 3 + codex-rs/cli/src/state_db_recovery.rs | 10 +- codex-rs/core/src/goals.rs | 30 ++++ codex-rs/core/src/session/tests.rs | 61 ++++++++ codex-rs/state/BUILD.bazel | 2 +- .../goals_migrations/0001_thread_goals.sql | 18 +++ .../migrations/0034_drop_thread_goals.sql | 1 + codex-rs/state/src/lib.rs | 5 + codex-rs/state/src/migrations.rs | 5 + codex-rs/state/src/runtime.rs | 133 +++++++++++++----- codex-rs/state/src/runtime/goals.rs | 65 --------- codex-rs/state/src/runtime/threads.rs | 64 +++++++++ codex-rs/state/src/telemetry.rs | 2 + 18 files changed, 330 insertions(+), 116 deletions(-) create mode 100644 codex-rs/state/goals_migrations/0001_thread_goals.sql create mode 100644 codex-rs/state/migrations/0034_drop_thread_goals.sql diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 142df216b5..b62b2bdc40 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -519,10 +519,9 @@ pub async fn run_main_with_transport_options( let state_db = match rollout_state_db::try_init(&config).await { Ok(state_db) => Some(state_db), Err(err) => { - let state_db_path = codex_state::state_db_path(config.sqlite_home.as_path()); return Err(std::io::Error::other(format!( - "failed to initialize sqlite state db at {}: {err}", - state_db_path.display() + "failed to initialize sqlite state runtime under {}: {err}", + config.sqlite_home.display() ))); } }; diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 0133114b84..cf1e58345b 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -148,6 +148,7 @@ impl ThreadGoalRequestProcessor { thread.prepare_external_goal_mutation().await; } + let should_set_thread_preview = objective.is_some(); let (goal, previous_status) = (if let Some(objective) = objective { let existing_goal = state_db .thread_goals() @@ -221,6 +222,13 @@ impl ThreadGoalRequestProcessor { .map(|goal| (goal, previous_status)) }) .map_err(|err| invalid_request(err.to_string()))?; + if should_set_thread_preview + && let Err(err) = state_db + .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) + .await + { + warn!("failed to set empty thread preview from goal objective for {thread_id}: {err}"); + } let external_goal_set = ExternalGoalSet { goal: goal.clone(), previous_status, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c9a5ae1485..8bcad54efe 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -994,7 +994,7 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", - "materialized thread", + "", Some("mock_provider"), /*git_info*/ None, )?; @@ -1028,6 +1028,11 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; let thread_id = ThreadId::from_string(&thread_id)?; + let thread_metadata = state_db + .get_thread(thread_id) + .await? + .expect("thread metadata should exist"); + assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing")); let persisted_goal = state_db .thread_goals() .get_thread_goal(thread_id) @@ -1066,8 +1071,13 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> .get_thread_goal(thread_id) .await? .expect("goal should still exist"); + let thread_metadata = state_db + .get_thread(thread_id) + .await? + .expect("thread metadata should still exist"); assert_eq!(persisted_goal.goal_id, updated_goal.goal_id); + assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing")); assert_eq!(edit.goal.objective, "keep polishing with clearer wording"); assert_eq!(edit.goal.status, ThreadGoalStatus::BudgetLimited); assert_eq!(edit.goal.token_budget, Some(40)); diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index f2b1bca6d7..aec57c1e82 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1943,13 +1943,11 @@ async fn state_check(config: &Config) -> DoctorCheck { path_readiness(&mut details, "CODEX_HOME", &config.codex_home); path_readiness(&mut details, "log dir", &config.log_dir); path_readiness(&mut details, "sqlite home", &config.sqlite_home); - let state_db = codex_state::state_db_path(&config.sqlite_home); - let log_db = codex_state::logs_db_path(&config.sqlite_home); - path_readiness(&mut details, "state DB", &state_db); - path_readiness(&mut details, "log DB", &log_db); let mut integrity_failures = Vec::new(); - sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await; - sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).await; + for db in codex_state::runtime_db_paths(&config.sqlite_home) { + path_readiness(&mut details, db.label, &db.path); + sqlite_integrity_detail(&mut details, &mut integrity_failures, db.label, &db.path).await; + } rollout_stats_details(&mut details, &config.codex_home); standalone_release_cache_details(&mut details); diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index da5fd20ce8..2e58b1136a 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -669,10 +669,14 @@ fn terminal_summary(check: &DoctorCheck) -> String { } fn state_summary(check: &DoctorCheck) -> String { - let state_ok = - detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); - let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); - if state_ok && log_ok { + let databases_ok = [ + "state DB integrity", + "log DB integrity", + "goals DB integrity", + ] + .into_iter() + .all(|label| detail::detail_value(check, label).is_some_and(|value| value == "ok")); + if check.status == CheckStatus::Ok && databases_ok { "databases healthy".to_string() } else { check.summary.clone() diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 4b5f49991b..0105077620 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -294,6 +294,7 @@ fn state_details(parsed: &[ParsedDetail]) -> Vec { push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); push_database_row(&mut out, parsed, "state DB"); push_database_row(&mut out, parsed, "log DB"); + push_database_row(&mut out, parsed, "goals DB"); for (source, label) in [ ("active rollout files", "active rollouts"), @@ -317,8 +318,10 @@ fn state_details(parsed: &[ParsedDetail]) -> Vec { "sqlite home", "state DB", "log DB", + "goals DB", "state DB integrity", "log DB integrity", + "goals DB integrity", "active rollout files", "archived rollout files", ], diff --git a/codex-rs/cli/src/state_db_recovery.rs b/codex-rs/cli/src/state_db_recovery.rs index 8db134540a..7aeffaca3a 100644 --- a/codex-rs/cli/src/state_db_recovery.rs +++ b/codex-rs/cli/src/state_db_recovery.rs @@ -48,10 +48,9 @@ pub(crate) async fn repair_files( Err(err) => return Err(err), } - let logs_db_path = codex_state::logs_db_path(sqlite_home); - for path in sqlite_paths(state_db_path) + for path in codex_state::runtime_db_paths(sqlite_home) .into_iter() - .chain(sqlite_paths(logs_db_path.as_path())) + .flat_map(|db| sqlite_paths(db.path.as_path())) { if tokio::fs::try_exists(path.as_path()).await? { backups.push(backup_path(path.as_path(), &repair_suffix).await?); @@ -137,19 +136,22 @@ mod tests { let temp_dir = TempDir::new()?; let state_path = codex_state::state_db_path(temp_dir.path()); let logs_path = codex_state::logs_db_path(temp_dir.path()); + let goals_path = codex_state::goals_db_path(temp_dir.path()); let state_sidecars = sqlite_paths(state_path.as_path()); tokio::fs::write(state_path.as_path(), b"state").await?; tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?; tokio::fs::write(logs_path.as_path(), b"logs").await?; + tokio::fs::write(goals_path.as_path(), b"goals").await?; let startup_error = LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string()); let backups = repair_files(&startup_error).await?; - assert_eq!(backups.len(), 3); + assert_eq!(backups.len(), 4); assert!(!tokio::fs::try_exists(state_path.as_path()).await?); assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?); assert!(!tokio::fs::try_exists(logs_path.as_path()).await?); + assert!(!tokio::fs::try_exists(goals_path.as_path()).await?); for backup in backups { assert!(tokio::fs::try_exists(backup.as_path()).await?); } diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 13f8cc95c8..4b3e237d53 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -23,6 +23,7 @@ use codex_otel::GOAL_DURATION_SECONDS_METRIC; use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_otel::GOAL_USAGE_LIMITED_METRIC; +use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -530,6 +531,14 @@ impl Session { })? }; + if objective.is_some() { + set_thread_preview_from_goal_objective( + &state_db, + self.conversation_id, + goal.objective.as_str(), + ) + .await; + } let goal_status = goal.status; let goal_id = goal.goal_id.clone(); let previous_status_for_goal = if replacing_goal { @@ -611,6 +620,12 @@ impl Session { ) })?; + set_thread_preview_from_goal_objective( + &state_db, + self.conversation_id, + goal.objective.as_str(), + ) + .await; let goal_id = goal.goal_id.clone(); self.emit_goal_created_metric(); let goal = protocol_goal_from_state(goal); @@ -1508,6 +1523,21 @@ impl Session { } } +async fn set_thread_preview_from_goal_objective( + state_db: &StateDbHandle, + thread_id: ThreadId, + objective: &str, +) { + if let Err(err) = state_db + .set_thread_preview_if_empty(thread_id, objective) + .await + { + tracing::warn!( + "failed to set empty thread preview from goal objective for {thread_id}: {err}" + ); + } +} + fn should_ignore_goal_for_mode(mode: ModeKind) -> bool { mode == ModeKind::Plan } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9d10f7b90b..c3528be8fc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -54,6 +54,7 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; +use crate::goals::CreateGoalRequest; use crate::goals::ExternalGoalPreviousStatus; use crate::goals::ExternalGoalSet; use crate::goals::GoalRuntimeEvent; @@ -8415,6 +8416,66 @@ async fn goal_test_state_db(sess: &Session) -> anyhow::Result anyhow::Result<()> { + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let state_db = goal_test_state_db(sess.as_ref()).await?; + + let page = state_db + .list_threads( + /*page_size*/ 10, + codex_state::ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: codex_state::SortKey::UpdatedAt, + sort_direction: codex_state::SortDirection::Desc, + search_term: None, + }, + ) + .await?; + assert!(page.items.is_empty()); + + sess.create_thread_goal( + tc.as_ref(), + CreateGoalRequest { + objective: "Keep improving the benchmark".to_string(), + token_budget: None, + }, + ) + .await?; + + let page = state_db + .list_threads( + /*page_size*/ 10, + codex_state::ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: codex_state::SortKey::UpdatedAt, + sort_direction: codex_state::SortDirection::Desc, + search_term: None, + }, + ) + .await?; + let ids = page + .items + .iter() + .map(|thread| thread.id) + .collect::>(); + assert_eq!(vec![sess.conversation_id], ids); + assert_eq!( + Some("Keep improving the benchmark"), + page.items[0].preview.as_deref() + ); + + Ok(()) +} + #[tokio::test] async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> { let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; diff --git a/codex-rs/state/BUILD.bazel b/codex-rs/state/BUILD.bazel index e10ac17c90..b3f0fecab7 100644 --- a/codex-rs/state/BUILD.bazel +++ b/codex-rs/state/BUILD.bazel @@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "state", crate_name = "codex_state", - compile_data = glob(["logs_migrations/**", "migrations/**"]), + compile_data = glob(["goals_migrations/**", "logs_migrations/**", "migrations/**"]), ) diff --git a/codex-rs/state/goals_migrations/0001_thread_goals.sql b/codex-rs/state/goals_migrations/0001_thread_goals.sql new file mode 100644 index 0000000000..943226ee8a --- /dev/null +++ b/codex-rs/state/goals_migrations/0001_thread_goals.sql @@ -0,0 +1,18 @@ +CREATE TABLE thread_goals ( + thread_id TEXT PRIMARY KEY NOT NULL, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ( + 'active', + 'paused', + 'blocked', + 'usage_limited', + 'budget_limited', + 'complete' + )), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); diff --git a/codex-rs/state/migrations/0034_drop_thread_goals.sql b/codex-rs/state/migrations/0034_drop_thread_goals.sql new file mode 100644 index 0000000000..8954bd6d5b --- /dev/null +++ b/codex-rs/state/migrations/0034_drop_thread_goals.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS thread_goals; diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index aeb8b2d13b..8746ff3a0d 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -50,12 +50,16 @@ pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; pub use runtime::GoalStore; pub use runtime::RemoteControlEnrollmentRecord; +pub use runtime::RuntimeDbPath; pub use runtime::ThreadFilterOptions; pub use runtime::ThreadGoalAccountingMode; pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::ThreadGoalUpdate; +pub use runtime::goals_db_filename; +pub use runtime::goals_db_path; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; +pub use runtime::runtime_db_paths; pub use runtime::sqlite_integrity_check; pub use runtime::state_db_filename; pub use runtime::state_db_path; @@ -69,6 +73,7 @@ pub use telemetry::record_fallback; pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; pub const LOGS_DB_FILENAME: &str = "logs_2.sqlite"; +pub const GOALS_DB_FILENAME: &str = "goals_1.sqlite"; pub const STATE_DB_FILENAME: &str = "state_5.sqlite"; /// Errors encountered during DB operations. Tags: [stage] diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 883129a943..526e958b5a 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -4,6 +4,7 @@ use sqlx::migrate::Migrator; pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); pub(crate) static LOGS_MIGRATOR: Migrator = sqlx::migrate!("./logs_migrations"); +pub(crate) static GOALS_MIGRATOR: Migrator = sqlx::migrate!("./goals_migrations"); /// Allow an older Codex binary to open a database that has already been /// migrated by a newer binary running in parallel. @@ -27,3 +28,7 @@ pub(crate) fn runtime_state_migrator() -> Migrator { pub(crate) fn runtime_logs_migrator() -> Migrator { runtime_migrator(&LOGS_MIGRATOR) } + +pub(crate) fn runtime_goals_migrator() -> Migrator { + runtime_migrator(&GOALS_MIGRATOR) +} diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index e95cc7cc8a..ac03f227da 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -5,6 +5,7 @@ use crate::AgentJobItemCreateParams; use crate::AgentJobItemStatus; use crate::AgentJobProgress; use crate::AgentJobStatus; +use crate::GOALS_DB_FILENAME; use crate::LOGS_DB_FILENAME; use crate::LogEntry; use crate::LogQuery; @@ -15,6 +16,7 @@ use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; +use crate::migrations::runtime_goals_migrator; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_state_migrator; use crate::model::AgentJobRow; @@ -80,6 +82,53 @@ pub use threads::ThreadFilterOptions; const LOG_PARTITION_SIZE_LIMIT_BYTES: i64 = 10 * 1024 * 1024; const LOG_PARTITION_ROW_LIMIT: i64 = 1_000; +#[derive(Clone, Copy)] +struct RuntimeDbSpec { + label: &'static str, + filename: &'static str, + kind: DbKind, + open_phase: &'static str, + migrate_phase: &'static str, +} + +impl RuntimeDbSpec { + fn path(self, codex_home: &Path) -> PathBuf { + codex_home.join(self.filename) + } +} + +const STATE_DB: RuntimeDbSpec = RuntimeDbSpec { + label: "state DB", + filename: STATE_DB_FILENAME, + kind: DbKind::State, + open_phase: "open_state", + migrate_phase: "migrate_state", +}; + +const LOGS_DB: RuntimeDbSpec = RuntimeDbSpec { + label: "log DB", + filename: LOGS_DB_FILENAME, + kind: DbKind::Logs, + open_phase: "open_logs", + migrate_phase: "migrate_logs", +}; + +const GOALS_DB: RuntimeDbSpec = RuntimeDbSpec { + label: "goals DB", + filename: GOALS_DB_FILENAME, + kind: DbKind::Goals, + open_phase: "open_goals", + migrate_phase: "migrate_goals", +}; + +const RUNTIME_DBS: [RuntimeDbSpec; 3] = [STATE_DB, LOGS_DB, GOALS_DB]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuntimeDbPath { + pub label: &'static str, + pub path: PathBuf, +} + #[derive(Clone)] pub struct StateRuntime { codex_home: PathBuf, @@ -122,8 +171,10 @@ impl StateRuntime { tokio::fs::create_dir_all(&codex_home).await?; let state_migrator = runtime_state_migrator(); let logs_migrator = runtime_logs_migrator(); - let state_path = state_db_path(codex_home.as_path()); - let logs_path = logs_db_path(codex_home.as_path()); + let goals_migrator = runtime_goals_migrator(); + let state_path = STATE_DB.path(codex_home.as_path()); + let logs_path = LOGS_DB.path(codex_home.as_path()); + let goals_path = GOALS_DB.path(codex_home.as_path()); let pool = match open_state_sqlite(&state_path, &state_migrator, telemetry_override).await { Ok(db) => Arc::new(db), Err(err) => { @@ -139,6 +190,14 @@ impl StateRuntime { return Err(err); } }; + let goals_pool = + match open_goals_sqlite(&goals_path, &goals_migrator, telemetry_override).await { + Ok(db) => Arc::new(db), + Err(err) => { + warn!("failed to open goals db at {}: {err}", goals_path.display()); + return Err(err); + } + }; let started = Instant::now(); let backfill_state_result = ensure_backfill_state_row_in_pool(pool.as_ref()).await; crate::telemetry::record_init_result( @@ -165,7 +224,7 @@ impl StateRuntime { let thread_updated_at_millis = thread_updated_at_millis_result?; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); let runtime = Arc::new(Self { - thread_goals: GoalStore::new(Arc::clone(&pool)), + thread_goals: GoalStore::new(Arc::clone(&goals_pool)), pool, logs_pool, codex_home, @@ -209,15 +268,7 @@ async fn open_state_sqlite( // New state DBs should use incremental auto-vacuum, but retrofitting an // existing DB requires a full VACUUM. Do not attempt that during process // startup: it is maintenance work that can contend with foreground writers. - open_sqlite( - path, - migrator, - DbKind::State, - "open_state", - "migrate_state", - telemetry_override, - ) - .await + open_sqlite(path, migrator, STATE_DB, telemetry_override).await } async fn open_logs_sqlite( @@ -225,23 +276,21 @@ async fn open_logs_sqlite( migrator: &Migrator, telemetry_override: Option<&dyn DbTelemetry>, ) -> anyhow::Result { - open_sqlite( - path, - migrator, - DbKind::Logs, - "open_logs", - "migrate_logs", - telemetry_override, - ) - .await + open_sqlite(path, migrator, LOGS_DB, telemetry_override).await +} + +async fn open_goals_sqlite( + path: &Path, + migrator: &Migrator, + telemetry_override: Option<&dyn DbTelemetry>, +) -> anyhow::Result { + open_sqlite(path, migrator, GOALS_DB, telemetry_override).await } async fn open_sqlite( path: &Path, migrator: &Migrator, - db: DbKind, - open_phase: &'static str, - migrate_phase: &'static str, + spec: RuntimeDbSpec, telemetry_override: Option<&dyn DbTelemetry>, ) -> anyhow::Result { let options = base_sqlite_options(path).auto_vacuum(SqliteAutoVacuum::Incremental); @@ -253,8 +302,8 @@ async fn open_sqlite( .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, - db, - open_phase, + spec.kind, + spec.open_phase, started.elapsed(), &pool_result, ); @@ -263,8 +312,8 @@ async fn open_sqlite( let migrate_result = migrator.run(&pool).await.map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, - db, - migrate_phase, + spec.kind, + spec.migrate_phase, started.elapsed(), &migrate_result, ); @@ -291,19 +340,37 @@ ON CONFLICT(id) DO NOTHING } pub fn state_db_filename() -> String { - STATE_DB_FILENAME.to_string() + STATE_DB.filename.to_string() } pub fn state_db_path(codex_home: &Path) -> PathBuf { - codex_home.join(state_db_filename()) + STATE_DB.path(codex_home) } pub fn logs_db_filename() -> String { - LOGS_DB_FILENAME.to_string() + LOGS_DB.filename.to_string() } pub fn logs_db_path(codex_home: &Path) -> PathBuf { - codex_home.join(logs_db_filename()) + LOGS_DB.path(codex_home) +} + +pub fn goals_db_filename() -> String { + GOALS_DB.filename.to_string() +} + +pub fn goals_db_path(codex_home: &Path) -> PathBuf { + GOALS_DB.path(codex_home) +} + +pub fn runtime_db_paths(codex_home: &Path) -> Vec { + RUNTIME_DBS + .iter() + .map(|spec| RuntimeDbPath { + label: spec.label, + path: spec.path(codex_home), + }) + .collect() } /// Run SQLite's built-in integrity check against an existing database file. @@ -510,6 +577,8 @@ mod tests { "migrate_state", "open_logs", "migrate_logs", + "open_goals", + "migrate_goals", "ensure_backfill_state", "post_init_query", ] diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 9de561ef2d..bf5a7bd825 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -115,8 +115,6 @@ RETURNING .fetch_one(self.pool.as_ref()) .await?; - self.set_thread_preview_if_empty(thread_id, objective) - .await?; thread_goal_from_row(&row) } @@ -166,10 +164,6 @@ RETURNING .fetch_optional(self.pool.as_ref()) .await?; - if row.is_some() { - self.set_thread_preview_if_empty(thread_id, objective) - .await?; - } row.map(|row| thread_goal_from_row(&row)).transpose() } @@ -478,29 +472,6 @@ RETURNING let updated = thread_goal_from_row(&row)?; Ok(ThreadGoalAccountingOutcome::Updated(updated)) } - - async fn set_thread_preview_if_empty( - &self, - thread_id: ThreadId, - preview: &str, - ) -> anyhow::Result<()> { - let preview = preview.trim(); - if preview.is_empty() { - return Ok(()); - } - sqlx::query( - r#" -UPDATE threads -SET preview = ? -WHERE id = ? AND preview = '' - "#, - ) - .bind(preview) - .bind(thread_id.to_string()) - .execute(self.pool.as_ref()) - .await?; - Ok(()) - } } fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result { @@ -643,42 +614,6 @@ mod tests { ); } - #[tokio::test] - async fn replace_thread_goal_sets_preview_when_empty() { - let runtime = test_runtime().await; - let thread_id = test_thread_id(); - let mut metadata = test_thread_metadata( - runtime.codex_home(), - thread_id, - runtime.codex_home().join("workspace"), - ); - metadata.preview = None; - metadata.first_user_message = None; - runtime - .upsert_thread(&metadata) - .await - .expect("test thread should be upserted"); - - runtime - .thread_goals() - .replace_thread_goal( - thread_id, - "optimize the benchmark", - crate::ThreadGoalStatus::Active, - /*token_budget*/ None, - ) - .await - .expect("goal replacement should succeed"); - - let metadata = runtime - .get_thread(thread_id) - .await - .expect("thread metadata should load") - .expect("thread should exist"); - assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark")); - assert_eq!(metadata.first_user_message, None); - } - #[tokio::test] async fn replace_thread_goal_applies_budget_limit_immediately() { let runtime = test_runtime().await; diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c8c48db721..c7030d7e44 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -51,6 +51,29 @@ WHERE threads.id = ? Ok(row.and_then(|row| row.try_get("memory_mode").ok())) } + pub async fn set_thread_preview_if_empty( + &self, + thread_id: ThreadId, + preview: &str, + ) -> anyhow::Result { + let preview = preview.trim(); + if preview.is_empty() { + return Ok(false); + } + let result = sqlx::query( + r#" +UPDATE threads +SET preview = ? +WHERE id = ? AND preview = '' + "#, + ) + .bind(preview) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Get dynamic tools for a thread, if present. pub async fn get_dynamic_tools( &self, @@ -1569,6 +1592,47 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("migrated goal preview")); } + #[tokio::test] + async fn set_thread_preview_if_empty_only_fills_blank_preview() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000460").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.first_user_message = None; + metadata.preview = None; + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let empty_updated = runtime + .set_thread_preview_if_empty(thread_id, " ") + .await + .expect("empty preview update should succeed"); + assert!(!empty_updated); + let goal_updated = runtime + .set_thread_preview_if_empty(thread_id, " goal preview ") + .await + .expect("goal preview update should succeed"); + assert!(goal_updated); + let overwrite_updated = runtime + .set_thread_preview_if_empty(thread_id, "new preview") + .await + .expect("overwrite preview update should succeed"); + assert!(!overwrite_updated); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.preview.as_deref(), Some("goal preview")); + } + #[tokio::test] async fn update_thread_git_info_preserves_newer_non_git_metadata() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/state/src/telemetry.rs b/codex-rs/state/src/telemetry.rs index a4a26db0ce..da2b7de7a8 100644 --- a/codex-rs/state/src/telemetry.rs +++ b/codex-rs/state/src/telemetry.rs @@ -39,6 +39,7 @@ pub fn install_process_db_telemetry(telemetry: DbTelemetryHandle) -> bool { pub(crate) enum DbKind { State, Logs, + Goals, } impl DbKind { @@ -46,6 +47,7 @@ impl DbKind { match self { Self::State => "state", Self::Logs => "logs", + Self::Goals => "goals", } } } From 05e171094d058bcda38ee19a6f9401185a991718 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 11:24:09 +0200 Subject: [PATCH 101/114] Remove ToolsConfig from tool planning (#22835) ## Why `codex-tools` is meant to hold reusable tool primitives, but `ToolsConfig` had become a second copy of core runtime decisions instead of a small shared contract. It carried provider capabilities, auth/model gates, permission and environment state, web/search/image feature gates, multi-agent settings, and goal availability from core into `codex-tools` ([definition](https://github.com/openai/codex/blob/22dd9ad3929253ed24d7ee4f10f238e95ab25f37/codex-rs/tools/src/tool_config.rs#L97), [stored on each `TurnContext`](https://github.com/openai/codex/blob/22dd9ad3929253ed24d7ee4f10f238e95ab25f37/codex-rs/core/src/session/turn_context.rs#L87)). Every session/context variant then had to build and mutate that snapshot before assembling tools. This PR removes that master object instead of renaming it. Tool planning now reads the live `TurnContext`, where `codex-core` already owns those decisions, while `codex-tools` keeps only reusable primitives and a generic `ToolSetBuilder`/`ToolSet` accumulator. ## What Changed - Removed `ToolsConfig` / `ToolsConfigParams` from `codex-tools`; the crate keeps the shared helpers that still belong there, including request-user-input mode selection, shell backend/type resolution, `UnifiedExecShellMode`, and `ToolEnvironmentMode`. - Replaced config-snapshot planning with `ToolRouter::from_turn_context` and a `spec_plan` pipeline over `CoreToolPlanContext`, deriving provider capabilities, auth gates, model support, feature gates, environment count, goal support, multi-agent options, web search, and image generation from the authoritative turn state. - Added generic `codex_tools::ToolSetBuilder` / `ToolSet`, plus the small core adapter needed to accumulate `CoreToolRuntime` values and hosted model specs. - Added the `tool_family::shell` registration module and moved shell/unified-exec/memory accounting call sites to read the narrow per-turn fields directly. - Narrowed `TurnContext` to the remaining explicit per-turn fields needed by planning: `available_models`, `unified_exec_shell_mode`, and `goal_tools_supported`. - Reworked MCP exposure and tool-search setup so deferred/direct MCP behavior is driven by the current turn rather than a precomputed config snapshot. - Replaced the large expected-spec fixture tests with focused behavior-level coverage for shell tools, environments, goal and agent-job gates, MCP direct/deferred exposure, tool search, request-plugin-install, code mode, multi-agent mode, hosted tools, and extension executor dispatch. ## Verification - `cargo check -p codex-tools` - `cargo check -p codex-core --lib` - `cargo test -p codex-tools` - `cargo test -p codex-core spec_plan --lib` - `cargo test -p codex-core router --lib` --- codex-rs/core/src/mcp_tool_exposure.rs | 5 +- codex-rs/core/src/mcp_tool_exposure_test.rs | 43 +- codex-rs/core/src/memory_usage.rs | 10 +- codex-rs/core/src/session/mod.rs | 5 +- codex-rs/core/src/session/review.rs | 69 +- codex-rs/core/src/session/tests.rs | 15 +- codex-rs/core/src/session/turn.rs | 10 +- codex-rs/core/src/session/turn_context.rs | 148 +- .../core/src/stream_events_utils_tests.rs | 4 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../handlers/unified_exec/exec_command.rs | 4 +- codex-rs/core/src/tools/mod.rs | 1 + codex-rs/core/src/tools/registry.rs | 11 +- codex-rs/core/src/tools/router.rs | 18 +- codex-rs/core/src/tools/router_tests.rs | 20 +- codex-rs/core/src/tools/spec_plan.rs | 578 +-- .../core/src/tools/spec_plan_model_tests.rs | 1401 ------- codex-rs/core/src/tools/spec_plan_tests.rs | 3478 ++++------------- codex-rs/core/src/tools/tool_family/mod.rs | 1 + codex-rs/core/src/tools/tool_family/shell.rs | 72 + .../core/src/unified_exec/process_manager.rs | 6 +- codex-rs/tools/README.md | 8 +- codex-rs/tools/src/lib.rs | 4 +- codex-rs/tools/src/tool_config.rs | 356 +- codex-rs/tools/src/tool_config_tests.rs | 391 +- 25 files changed, 1427 insertions(+), 5233 deletions(-) delete mode 100644 codex-rs/core/src/tools/spec_plan_model_tests.rs create mode 100644 codex-rs/core/src/tools/tool_family/mod.rs create mode 100644 codex-rs/core/src/tools/tool_family/shell.rs diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index 8f8be11072..e58be65f9c 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; -use codex_tools::ToolsConfig; use crate::config::Config; use crate::connectors; @@ -19,7 +18,7 @@ pub(crate) fn build_mcp_tool_exposure( all_mcp_tools: &[McpToolInfo], connectors: Option<&[connectors::AppInfo]>, config: &Config, - tools_config: &ToolsConfig, + search_tool_enabled: bool, ) -> McpToolExposure { let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools); if let Some(connectors) = connectors { @@ -30,7 +29,7 @@ pub(crate) fn build_mcp_tool_exposure( )); } - let should_defer = tools_config.search_tool + let should_defer = search_tool_enabled && (config .features .enabled(Feature::ToolSearchAlwaysDeferMcpTools) diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 06c9d12559..fba7fe1f84 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -2,17 +2,9 @@ use std::collections::HashSet; use std::sync::Arc; use codex_features::Feature; -use codex_features::Features; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; -use codex_models_manager::test_support::construct_model_info_offline_for_tests; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SessionSource; use codex_tools::ToolName; -use codex_tools::ToolsConfig; -use codex_tools::ToolsConfigParams; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; @@ -94,34 +86,14 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet { .collect() } -async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig { - let config = test_config().await; - let model_info = - construct_model_info_offline_for_tests("gpt-5.4", &config.to_models_manager_config()); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.search_tool = search_tool; - tools_config -} - #[tokio::test] async fn directly_exposes_small_effective_tool_sets() { let config = test_config().await; - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); - let exposure = - build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); + let exposure = build_mcp_tool_exposure( + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + ); assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools)); assert!(exposure.deferred_tools.is_none()); @@ -130,11 +102,11 @@ async fn directly_exposes_small_effective_tool_sets() { #[tokio::test] async fn searches_large_effective_tool_sets() { let config = test_config().await; - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); - let exposure = - build_mcp_tool_exposure(&mcp_tools, /*connectors*/ None, &config, &tools_config); + let exposure = build_mcp_tool_exposure( + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + ); assert!(exposure.direct_tools.is_empty()); let deferred_tools = exposure @@ -151,7 +123,6 @@ async fn always_defer_feature_defers_apps_too() { .features .enable(Feature::ToolSearchAlwaysDeferMcpTools) .expect("test config should allow feature update"); - let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await; let mcp_tools = vec![ make_mcp_tool( "rmcp", @@ -176,7 +147,7 @@ async fn always_defer_feature_defers_apps_too() { &mcp_tools, Some(connectors.as_slice()), &config, - &tools_config, + /*search_tool_enabled*/ true, ); assert!(exposure.direct_tools.is_empty()); diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index baa64459a2..9e9601c730 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -43,14 +43,16 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { - if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + if !invocation.turn.config.permissions.allow_login_shell + && params.login == Some(true) + { #[allow(deprecated)] let cwd = invocation.turn.resolve_path(params.workdir).to_path_buf(); return (Vec::new(), cwd); } let use_login_shell = params .login - .unwrap_or(invocation.turn.tools_config.allow_login_shell); + .unwrap_or(invocation.turn.config.permissions.allow_login_shell); let command = invocation .session .user_shell() @@ -65,8 +67,8 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec, turn_context: Arc) -> ToolCallRuntime { - let router = Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + let router = Arc::new(ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, @@ -9259,8 +9259,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { .await }; let deferred_mcp_tools = Some(tools.clone()); - let router = ToolRouter::from_config( - &turn_context.tools_config, + let router = ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { deferred_mcp_tools, mcp_tools: Some(tools), @@ -9828,9 +9828,10 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); - let command = session - .user_shell() - .derive_exec_args(command_script, turn_context.tools_config.allow_login_shell); + let command = session.user_shell().derive_exec_args( + command_script, + turn_context.config.permissions.allow_login_shell, + ); let exec_approval_requirement = session .services .exec_policy diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 27d35504f9..5c4567b9fc 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -55,6 +55,8 @@ use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; use crate::tools::router::extension_tool_executors; +use crate::tools::spec_plan::search_tool_enabled; +use crate::tools::spec_plan::tool_suggest_enabled; use crate::turn_diff_tracker::TurnDiffTracker; use crate::turn_timing::record_turn_ttft_metric; use crate::util::backoff; @@ -1120,7 +1122,7 @@ pub(crate) async fn built_tools( None }; let auth = sess.services.auth_manager.auth().await; - let discoverable_tools = if apps_enabled && turn_context.tools_config.tool_suggest { + let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) { if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, @@ -1152,12 +1154,12 @@ pub(crate) async fn built_tools( &all_mcp_tools, connectors.as_deref(), &turn_context.config, - &turn_context.tools_config, + search_tool_enabled(turn_context), ); let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools); let deferred_mcp_tools = mcp_tool_exposure.deferred_tools; - Ok(Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + Ok(Arc::new(ToolRouter::from_turn_context( + turn_context, ToolRouterParams { mcp_tools, deferred_mcp_tools, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b14999d2cc..3338d0a558 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -14,10 +14,6 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { - auth_manager.is_some_and(AuthManager::current_auth_uses_codex_backend) -} - #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, @@ -84,7 +80,9 @@ pub struct TurnContext { pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, - pub(crate) tools_config: ToolsConfig, + pub(crate) available_models: Vec, + pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, + pub(crate) goal_tools_supported: bool, pub features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -156,6 +154,14 @@ impl TurnContext { self.features.apps_enabled_for_auth(uses_codex_backend) } + pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode { + ToolEnvironmentMode::from_count(self.environments.turn_environments.len()) + } + + pub(crate) fn goal_tools_enabled(&self) -> bool { + self.goal_tools_supported && self.features.get().enabled(Feature::Goals) + } + pub(crate) async fn with_model( &self, model: String, @@ -195,61 +201,9 @@ impl TurnContext { /*developer_instructions*/ None, ); let features = self.features.clone(); - let provider_capabilities = self.provider.capabilities(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &models_manager - .list_models(RefreshStrategy::OnlineIfUncached) - .await, - features: &features, - image_generation_tool_auth_allowed: image_generation_tool_auth_allowed( - self.auth_manager.as_deref(), - ), - web_search_mode: self.tools_config.web_search_mode, - session_source: self.session_source.clone(), - permission_profile: &self.permission_profile, - windows_sandbox_level: self.windows_sandbox_level, - }) - .with_namespace_tools_capability(provider_capabilities.namespace_tools) - .with_image_generation_capability(provider_capabilities.image_generation) - .with_web_search_capability(provider_capabilities.web_search) - .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) - .with_web_search_config(self.tools_config.web_search_config.clone()) - .with_allow_login_shell(self.tools_config.allow_login_shell) - .with_environment_mode(self.tools_config.environment_mode) - .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) - .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) - .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) - .with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone()) - .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) - .with_goal_tools_allowed(self.tools_config.goal_tools) - .with_max_concurrent_threads_per_session( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.max_concurrent_threads_per_session), - ) - .with_wait_agent_min_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.min_wait_timeout_ms), - ) - .with_wait_agent_max_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.max_wait_timeout_ms), - ) - .with_wait_agent_default_timeout_ms( - config - .features - .enabled(Feature::MultiAgentV2) - .then_some(config.multi_agent_v2.default_wait_timeout_ms), - ) - .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( - &config.agent_roles, - )); + let available_models = models_manager + .list_models(RefreshStrategy::OnlineIfUncached) + .await; Self { sub_id: self.sub_id.clone(), @@ -283,7 +237,9 @@ impl TurnContext { network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, shell_environment_policy: self.shell_environment_policy.clone(), - tools_config, + available_models, + unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), + goal_tools_supported: self.goal_tools_supported, features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), @@ -503,72 +459,18 @@ impl Session { model_info.slug.as_str(), ); let session_source = session_configuration.session_source.clone(); - let image_generation_tool_auth_allowed = - image_generation_tool_auth_allowed(auth_manager.as_deref()); let auth_manager_for_context = auth_manager.clone(); let provider_for_context = create_model_provider(provider, auth_manager); - let provider_capabilities = provider_for_context.capabilities(); let session_telemetry_for_context = session_telemetry; - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &models_manager.try_list_models().unwrap_or_default(), - features: &per_turn_config.features, - image_generation_tool_auth_allowed, - web_search_mode: Some(per_turn_config.web_search_mode.value()), - session_source: session_source.clone(), - permission_profile: &session_configuration.permission_profile(), - windows_sandbox_level: session_configuration.windows_sandbox_level, - }) - .with_namespace_tools_capability(provider_capabilities.namespace_tools) - .with_image_generation_capability(provider_capabilities.image_generation) - .with_web_search_capability(provider_capabilities.web_search) - .with_unified_exec_shell_mode_for_session( + let available_models = models_manager.try_list_models().unwrap_or_default(); + let shell_command_backend = + shell_command_backend_for_features(per_turn_config.features.get()); + let unified_exec_shell_mode = UnifiedExecShellMode::for_session( + shell_command_backend, crate::tools::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, - ) - .with_web_search_config(per_turn_config.web_search_config.clone()) - .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_environment_mode(ToolEnvironmentMode::from_count( - environments.turn_environments.len(), - )) - .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) - .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) - .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) - .with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone()) - .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) - .with_goal_tools_allowed(goal_tools_supported) - .with_max_concurrent_threads_per_session( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some( - per_turn_config - .multi_agent_v2 - .max_concurrent_threads_per_session, - ), - ) - .with_wait_agent_min_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms), - ) - .with_wait_agent_max_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms), - ) - .with_wait_agent_default_timeout_ms( - per_turn_config - .features - .enabled(Feature::MultiAgentV2) - .then_some(per_turn_config.multi_agent_v2.default_wait_timeout_ms), - ) - .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( - &per_turn_config.agent_roles, - )); + ); let mut per_turn_config = per_turn_config; per_turn_config.service_tier = per_turn_config @@ -616,7 +518,9 @@ impl Session { network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), - tools_config, + available_models, + unified_exec_shell_mode, + goal_tools_supported, features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 2d095906ed..28ffbb7a6b 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -268,8 +268,8 @@ async fn handle_output_item_done_returns_contributed_last_agent_message() { session.services.extensions = Arc::new(builder.build()); let session = Arc::new(session); let turn_context = Arc::new(turn_context); - let router = Arc::new(ToolRouter::from_config( - &turn_context.tools_config, + let router = Arc::new(ToolRouter::from_turn_context( + &turn_context, crate::tools::router::ToolRouterParams { mcp_tools: None, deferred_mcp_tools: None, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 7e31b86d17..4dffdd688c 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -183,7 +183,7 @@ impl ToolExecutor for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - turn.tools_config.allow_login_shell, + turn.config.permissions.allow_login_shell, )?; let shell_type = Some(session.user_shell().shell_type.clone()); run_exec_like(RunExecLikeArgs { diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index b048227cf1..af1c752528 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -144,8 +144,8 @@ impl ToolExecutor for ExecCommandHandler { let resolved_command = get_command( &args, session.user_shell(), - &turn.tools_config.unified_exec_shell_mode, - turn.tools_config.allow_login_shell, + &turn.unified_exec_shell_mode, + turn.config.permissions.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; let command = resolved_command.command; diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 5b7a17f428..1171596f82 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod runtimes; pub(crate) mod sandboxing; pub(crate) mod spec_plan; pub(crate) mod tool_dispatch_trace; +pub(crate) mod tool_family; pub(crate) mod tool_search_entry; use std::borrow::Cow; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 363c3f2a01..96837497f8 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -287,8 +287,15 @@ impl ToolRegistry { } #[cfg(test)] - pub(crate) fn has_tool(&self, name: &ToolName) -> bool { - self.tool(name).is_some() + pub(crate) fn tool_names_for_test(&self) -> Vec { + let mut names = self.tools.keys().cloned().collect::>(); + names.sort(); + names + } + + #[cfg(test)] + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.tools.get(name).map(|tool| tool.exposure()) } pub(crate) fn create_diff_consumer( diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index a279ec88d9..cfd4776890 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -17,7 +17,6 @@ use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tokio_util::sync::CancellationToken; @@ -46,8 +45,8 @@ pub(crate) struct ToolRouterParams<'a> { } impl ToolRouter { - pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { - build_tool_router(config, params) + pub fn from_turn_context(turn_context: &TurnContext, params: ToolRouterParams<'_>) -> Self { + build_tool_router(turn_context, params) } pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec) -> Self { @@ -61,6 +60,19 @@ impl ToolRouter { self.model_visible_specs.clone() } + #[cfg(test)] + pub(crate) fn registered_tool_names_for_test(&self) -> Vec { + self.registry.tool_names_for_test() + } + + #[cfg(test)] + pub(crate) fn tool_exposure_for_test( + &self, + name: &ToolName, + ) -> Option { + self.registry.tool_exposure(name) + } + pub(crate) fn create_diff_consumer( &self, tool_name: &ToolName, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index c9bb4bcafc..92073d56b1 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -106,8 +106,8 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow .await .list_all_tools() .await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: Some(mcp_tools), @@ -172,8 +172,8 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() #[tokio::test] async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: Some(vec![ @@ -220,8 +220,8 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { #[tokio::test] async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, @@ -272,8 +272,8 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { }, ]; - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, @@ -328,8 +328,8 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow let (mut session, turn) = make_session_and_context().await; session.services.extensions = extension_tool_test_registry(); - let router = ToolRouter::from_config( - &turn.tools_config, + let router = ToolRouter::from_turn_context( + &turn, ToolRouterParams { deferred_mcp_tools: None, mcp_tools: None, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 3f355d2509..26c91523cc 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,6 +1,4 @@ -use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; +use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; @@ -8,8 +6,6 @@ use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; -use crate::tools::handlers::ExecCommandHandler; -use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; @@ -19,13 +15,10 @@ use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; -use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; -use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; use crate::tools::handlers::extension_tools::ExtensionToolAdapter; @@ -55,9 +48,15 @@ use crate::tools::registry::ToolRegistry; use crate::tools::registry::override_tool_exposure; use crate::tools::router::ToolRouter; use crate::tools::router::ToolRouterParams; +use crate::tools::tool_family::shell::ShellToolsOptions; +use crate::tools::tool_family::shell::build_shell_tools; +use codex_features::Feature; +use codex_login::AuthManager; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; @@ -68,9 +67,12 @@ use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolOutput; use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; +use codex_tools::can_request_original_image_detail; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; use codex_tools::default_namespace_description; +use codex_tools::request_user_input_available_modes; +use codex_tools::shell_command_backend_for_features; +use codex_tools::shell_type_for_model_and_features; use std::collections::BTreeMap; use std::collections::HashSet; use std::sync::Arc; @@ -78,8 +80,34 @@ use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; +type PlannedRuntime = Arc; + +#[derive(Default)] +struct PlannedTools { + runtimes: Vec, + hosted_specs: Vec, +} + +impl PlannedTools { + fn add_runtime(&mut self, runtime: T) + where + T: CoreToolRuntime + 'static, + { + self.runtimes.push(Arc::new(runtime)); + } + + fn add_runtime_arc(&mut self, runtime: PlannedRuntime) { + self.runtimes.push(runtime); + } + + fn runtimes(&self) -> &[PlannedRuntime] { + &self.runtimes + } +} + #[derive(Clone, Copy)] -struct ToolRegistryBuildParams<'a> { +struct CoreToolPlanContext<'a> { + turn_context: &'a TurnContext, mcp_tools: Option<&'a [ToolInfo]>, deferred_mcp_tools: Option<&'a [ToolInfo]>, discoverable_tools: Option<&'a [DiscoverableTool]>, @@ -89,13 +117,16 @@ struct ToolRegistryBuildParams<'a> { wait_agent_timeouts: WaitAgentTimeoutOptions, } -pub(crate) fn build_tool_router(config: &ToolsConfig, params: ToolRouterParams<'_>) -> ToolRouter { - let (model_visible_specs, registry) = build_tool_specs_and_registry(config, params); +pub(crate) fn build_tool_router( + turn_context: &TurnContext, + params: ToolRouterParams<'_>, +) -> ToolRouter { + let (model_visible_specs, registry) = build_tool_specs_and_registry(turn_context, params); ToolRouter::from_parts(registry, model_visible_specs) } fn build_tool_specs_and_registry( - config: &ToolsConfig, + turn_context: &TurnContext, params: ToolRouterParams<'_>, ) -> (Vec, ToolRegistry) { let ToolRouterParams { @@ -107,65 +138,73 @@ fn build_tool_specs_and_registry( } = params; let default_agent_type_description = crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); - let mut executors = collect_tool_executors( - config, - ToolRegistryBuildParams { - mcp_tools: mcp_tools.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors: &extension_tool_executors, - dynamic_tools, - default_agent_type_description: &default_agent_type_description, - wait_agent_timeouts: wait_agent_timeout_options(config), - }, - ); - append_tool_search_executor(config, &mut executors); - prepend_code_mode_executors(config, &mut executors); - build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) + let context = CoreToolPlanContext { + turn_context, + mcp_tools: mcp_tools.as_deref(), + deferred_mcp_tools: deferred_mcp_tools.as_deref(), + discoverable_tools: discoverable_tools.as_deref(), + extension_tool_executors: &extension_tool_executors, + dynamic_tools, + default_agent_type_description: &default_agent_type_description, + wait_agent_timeouts: wait_agent_timeout_options(turn_context), + }; + let mut planned_tools = PlannedTools::default(); + add_tool_sources(&context, &mut planned_tools); + append_tool_search_executor(&context, &mut planned_tools); + prepend_code_mode_executors(&context, &mut planned_tools); + build_model_visible_specs_and_registry(turn_context, planned_tools) } fn build_model_visible_specs_and_registry( - config: &ToolsConfig, - executors: Vec>, - hosted_specs: Vec, + turn_context: &TurnContext, + planned_tools: PlannedTools, ) -> (Vec, ToolRegistry) { + let PlannedTools { + runtimes, + hosted_specs, + } = planned_tools; let mut specs = Vec::new(); let mut seen_tool_names = HashSet::new(); - for executor in &executors { - let tool_name = executor.tool_name(); + for runtime in &runtimes { + let tool_name = runtime.tool_name(); if !seen_tool_names.insert(tool_name.clone()) { continue; } - let exposure = executor.exposure(); + let exposure = runtime.exposure(); if exposure.is_direct() - && !is_hidden_by_code_mode_only(config, &tool_name, exposure) - && let Some(spec) = executor.spec() + && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) + && let Some(spec) = runtime.spec() { - specs.push(spec_for_model_request(config, exposure, spec)); + specs.push(spec_for_model_request(turn_context, exposure, spec)); } } for spec in hosted_specs { - if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct) - { + if !is_hidden_by_code_mode_only( + turn_context, + &ToolName::plain(spec.name()), + ToolExposure::Direct, + ) { specs.push(spec); } } - let registry = ToolRegistry::from_tools(executors); + let registry = ToolRegistry::from_tools(runtimes); let model_visible_specs = merge_into_namespaces(specs) .into_iter() - .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) + .filter(|spec| { + namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_)) + }) .collect(); (model_visible_specs, registry) } fn spec_for_model_request( - config: &ToolsConfig, + turn_context: &TurnContext, exposure: ToolExposure, spec: ToolSpec, ) -> ToolSpec { - if config.code_mode_enabled + if code_mode_enabled(turn_context) && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(spec.name()) { @@ -175,33 +214,104 @@ fn spec_for_model_request( } } -pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { +pub(crate) fn hosted_model_tool_specs(turn_context: &TurnContext) -> Vec { let mut specs = Vec::new(); + let provider_capabilities = turn_context.provider.capabilities(); + let web_search_mode = provider_capabilities + .web_search + .then_some(turn_context.config.web_search_mode.value()); + let web_search_config = if provider_capabilities.web_search { + turn_context.config.web_search_config.as_ref() + } else { + None + }; if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { - web_search_mode: config.web_search_mode, - web_search_config: config.web_search_config.as_ref(), - web_search_tool_type: config.web_search_tool_type, + web_search_mode, + web_search_config, + web_search_tool_type: turn_context.model_info.web_search_tool_type, }) { specs.push(web_search_tool); } - if config.image_gen_tool { + if image_generation_tool_enabled(turn_context) { specs.push(create_image_generation_tool("png")); } specs } -fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { - if config.multi_agent_v2 { +pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { + turn_context.model_info.supports_search_tool +} + +pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { + let features = turn_context.features.get(); + features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins) +} + +fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.provider.capabilities().namespace_tools +} + +fn code_mode_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::CodeMode) +} + +fn code_mode_only_enabled(turn_context: &TurnContext) -> bool { + code_mode_enabled(turn_context) && turn_context.features.get().enabled(Feature::CodeModeOnly) +} + +fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::MultiAgentV2) +} + +fn collab_tools_enabled(turn_context: &TurnContext) -> bool { + multi_agent_v2_enabled(turn_context) || turn_context.features.get().enabled(Feature::Collab) +} + +fn goal_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.goal_tools_enabled() + && !matches!( + turn_context.session_source, + SessionSource::SubAgent(SubAgentSource::Review) + ) +} + +fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { + turn_context.features.get().enabled(Feature::SpawnCsv) +} + +fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { + agent_jobs_tools_enabled(turn_context) + && matches!( + &turn_context.session_source, + SessionSource::SubAgent(SubAgentSource::Other(label)) + if label.starts_with("agent_job:") + ) +} + +fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { + turn_context + .auth_manager + .as_deref() + .is_some_and(AuthManager::current_auth_uses_codex_backend) + && turn_context.provider.capabilities().image_generation + && turn_context + .features + .get() + .enabled(Feature::ImageGeneration) + && turn_context + .model_info + .input_modalities + .contains(&InputModality::Image) +} + +fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions { + if multi_agent_v2_enabled(turn_context) { return WaitAgentTimeoutOptions { - default_timeout_ms: config - .wait_agent_default_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS), - min_timeout_ms: config - .wait_agent_min_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS), - max_timeout_ms: config - .wait_agent_max_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS), + default_timeout_ms: turn_context.config.multi_agent_v2.default_wait_timeout_ms, + min_timeout_ms: turn_context.config.multi_agent_v2.min_wait_timeout_ms, + max_timeout_ms: turn_context.config.multi_agent_v2.max_wait_timeout_ms, }; } @@ -212,20 +322,34 @@ fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { } } -fn agent_type_description(config: &ToolsConfig, default_agent_type_description: &str) -> String { - if config.agent_type_description.is_empty() { +fn max_concurrent_threads_per_session(turn_context: &TurnContext) -> Option { + multi_agent_v2_enabled(turn_context).then_some( + turn_context + .config + .multi_agent_v2 + .max_concurrent_threads_per_session, + ) +} + +fn agent_type_description( + turn_context: &TurnContext, + default_agent_type_description: &str, +) -> String { + let agent_type_description = + crate::agent::role::spawn_tool_spec::build(&turn_context.config.agent_roles); + if agent_type_description.is_empty() { default_agent_type_description.to_string() } else { - config.agent_type_description.clone() + agent_type_description } } fn is_hidden_by_code_mode_only( - config: &ToolsConfig, + turn_context: &TurnContext, tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - config.code_mode_only_enabled + code_mode_only_enabled(turn_context) && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( tool_name, @@ -233,11 +357,11 @@ fn is_hidden_by_code_mode_only( } fn build_code_mode_executors( - config: &ToolsConfig, + turn_context: &TurnContext, executors: &[Arc], deferred_tools_available: bool, ) -> Vec> { - if !config.code_mode_enabled { + if !code_mode_enabled(turn_context) { return vec![]; } @@ -262,7 +386,7 @@ fn build_code_mode_executors( create_code_mode_tool( &enabled_tools, &namespace_descriptions, - config.code_mode_only_enabled, + code_mode_only_enabled(turn_context), deferred_tools_available, ), code_mode_nested_tool_specs, @@ -339,203 +463,201 @@ fn code_mode_namespace_descriptions( namespace_descriptions } -fn collect_tool_executors( - config: &ToolsConfig, - params: ToolRegistryBuildParams<'_>, -) -> Vec> { - let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; - let mut executors = Vec::>::new(); +fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + add_shell_tools(context, planned_tools); + add_mcp_resource_tools(context, planned_tools); + add_core_utility_tools(context, planned_tools); + add_collaboration_tools(context, planned_tools); + add_mcp_runtime_tools(context, planned_tools); + add_dynamic_tools(context, planned_tools); + add_extension_tools(context, planned_tools); + planned_tools + .hosted_specs + .extend(hosted_model_tool_specs(context.turn_context)); +} - if config.environment_mode.has_environment() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - match &config.shell_type { - ConfigShellToolType::UnifiedExec => { - executors.push(Arc::new(ExecCommandHandler::new( - ExecCommandHandlerOptions { - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - include_environment_id, - }, - ))); - executors.push(Arc::new(WriteStdinHandler)); - } - ConfigShellToolType::Disabled => {} - ConfigShellToolType::Default - | ConfigShellToolType::Local - | ConfigShellToolType::ShellCommand => { - executors.push(Arc::new(ShellCommandHandler::new( - ShellCommandHandlerOptions { - backend_config: config.shell_command_backend, - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - }, - ))); - } - } +fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + let features = turn_context.features.get(); + planned_tools + .runtimes + .extend(build_shell_tools(ShellToolsOptions { + shell_type: shell_type_for_model_and_features(&turn_context.model_info, features), + shell_command_backend: shell_command_backend_for_features(features), + environment_mode: turn_context.tool_environment_mode(), + allow_login_shell: turn_context.config.permissions.allow_login_shell, + exec_permission_approvals_enabled: features.enabled(Feature::ExecPermissionApprovals), + })); +} + +fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + if context.mcp_tools.is_some() { + planned_tools.add_runtime(ListMcpResourcesHandler); + planned_tools.add_runtime(ListMcpResourceTemplatesHandler); + planned_tools.add_runtime(ReadMcpResourceHandler); + } +} + +fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + let features = turn_context.features.get(); + let environment_mode = turn_context.tool_environment_mode(); + + planned_tools.add_runtime(PlanHandler); + if goal_tools_enabled(turn_context) { + planned_tools.add_runtime(GetGoalHandler); + planned_tools.add_runtime(CreateGoalHandler); + planned_tools.add_runtime(UpdateGoalHandler); } - if config.environment_mode.has_environment() - && config.shell_type != ConfigShellToolType::Disabled - { - match &config.shell_type { - ConfigShellToolType::UnifiedExec => { - executors.push(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } - ConfigShellToolType::Default - | ConfigShellToolType::Local - | ConfigShellToolType::ShellCommand - | ConfigShellToolType::Disabled => {} - } + planned_tools.add_runtime(RequestUserInputHandler { + available_modes: request_user_input_available_modes(features), + }); + + if features.enabled(Feature::RequestPermissionsTool) { + planned_tools.add_runtime(RequestPermissionsHandler); } - if params.mcp_tools.is_some() { - executors.push(Arc::new(ListMcpResourcesHandler)); - executors.push(Arc::new(ListMcpResourceTemplatesHandler)); - executors.push(Arc::new(ReadMcpResourceHandler)); - } - - executors.push(Arc::new(PlanHandler)); - if config.goal_tools { - executors.push(Arc::new(GetGoalHandler)); - executors.push(Arc::new(CreateGoalHandler)); - executors.push(Arc::new(UpdateGoalHandler)); - } - - executors.push(Arc::new(RequestUserInputHandler { - available_modes: config.request_user_input_available_modes.clone(), - })); - - if config.request_permissions_tool_enabled { - executors.push(Arc::new(RequestPermissionsHandler)); - } - - if config.tool_suggest + if tool_suggest_enabled(turn_context) && let Some(discoverable_tools) = - params.discoverable_tools.filter(|tools| !tools.is_empty()) + context.discoverable_tools.filter(|tools| !tools.is_empty()) { - executors.push(Arc::new(RequestPluginInstallHandler::new( - discoverable_tools, - ))); + planned_tools.add_runtime(RequestPluginInstallHandler::new(discoverable_tools)); } - if config.environment_mode.has_environment() && config.apply_patch_tool_type.is_some() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - executors.push(Arc::new(ApplyPatchHandler::new(include_environment_id))); + if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some() + { + let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); + planned_tools.add_runtime(ApplyPatchHandler::new(include_environment_id)); } - if config + if turn_context + .model_info .experimental_supported_tools .iter() .any(|tool| tool == "test_sync_tool") { - executors.push(Arc::new(TestSyncHandler)); + planned_tools.add_runtime(TestSyncHandler); } - if config.environment_mode.has_environment() { - let include_environment_id = - matches!(config.environment_mode, ToolEnvironmentMode::Multiple); - executors.push(Arc::new(ViewImageHandler::new(ViewImageToolOptions { - can_request_original_image_detail: config.can_request_original_image_detail, + if environment_mode.has_environment() { + let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); + planned_tools.add_runtime(ViewImageHandler::new(ViewImageToolOptions { + can_request_original_image_detail: can_request_original_image_detail( + &turn_context.model_info, + ), include_environment_id, - }))); + })); } +} - if config.collab_tools { - if config.multi_agent_v2 { - let exposure = if config.multi_agent_v2_non_code_mode_only { +fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let turn_context = context.turn_context; + if collab_tools_enabled(turn_context) { + if multi_agent_v2_enabled(turn_context) { + let exposure = if turn_context.config.multi_agent_v2.non_code_mode_only { ToolExposure::DirectModelOnly } else { ToolExposure::Direct }; - let tool_namespace = config - .namespace_tools - .then_some(config.multi_agent_v2_tool_namespace.as_deref()) + let tool_namespace = namespace_tools_enabled(turn_context) + .then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref()) .flatten(); let agent_type_description = - agent_type_description(config, params.default_agent_type_description); - executors.push(multi_agent_v2_handler( + agent_type_description(turn_context, context.default_agent_type_description); + planned_tools.add_runtime_arc(multi_agent_v2_handler( SpawnAgentHandlerV2::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), + available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, + hide_agent_type_model_reasoning: turn_context + .config + .multi_agent_v2 + .hide_spawn_agent_metadata, + include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, + usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), + max_concurrent_threads_per_session: max_concurrent_threads_per_session( + turn_context, + ), }), exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( SendMessageHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( FollowupTaskHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( - WaitAgentHandlerV2::new(params.wait_agent_timeouts), + planned_tools.add_runtime_arc(multi_agent_v2_handler( + WaitAgentHandlerV2::new(context.wait_agent_timeouts), exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( CloseAgentHandlerV2, exposure, tool_namespace, )); - executors.push(multi_agent_v2_handler( + planned_tools.add_runtime_arc(multi_agent_v2_handler( ListAgentsHandlerV2, exposure, tool_namespace, )); } else { let agent_type_description = - agent_type_description(config, params.default_agent_type_description); - executors.push(Arc::new(SpawnAgentHandler::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), + agent_type_description(turn_context, context.default_agent_type_description); + planned_tools.add_runtime(SpawnAgentHandler::new(SpawnAgentToolOptions { + available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - }))); - executors.push(Arc::new(SendInputHandler)); - executors.push(Arc::new(ResumeAgentHandler)); - executors.push(Arc::new(WaitAgentHandler::new(params.wait_agent_timeouts))); - executors.push(Arc::new(CloseAgentHandler)); + hide_agent_type_model_reasoning: turn_context + .config + .multi_agent_v2 + .hide_spawn_agent_metadata, + include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, + usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), + max_concurrent_threads_per_session: max_concurrent_threads_per_session( + turn_context, + ), + })); + planned_tools.add_runtime(SendInputHandler); + planned_tools.add_runtime(ResumeAgentHandler); + planned_tools.add_runtime(WaitAgentHandler::new(context.wait_agent_timeouts)); + planned_tools.add_runtime(CloseAgentHandler); } } - if config.agent_jobs_tools { - executors.push(Arc::new(SpawnAgentsOnCsvHandler)); - if config.agent_jobs_worker_tools { - executors.push(Arc::new(ReportAgentJobResultHandler)); + if agent_jobs_tools_enabled(turn_context) { + planned_tools.add_runtime(SpawnAgentsOnCsvHandler); + if agent_jobs_worker_tools_enabled(turn_context) { + planned_tools.add_runtime(ReportAgentJobResultHandler); } } +} - if let Some(mcp_tools) = params.mcp_tools { +fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + if let Some(mcp_tools) = context.mcp_tools { for tool in mcp_tools { - executors.push(Arc::new(McpHandler::new(tool.clone()))); + planned_tools.add_runtime(McpHandler::new(tool.clone())); } } - if let Some(deferred_mcp_tools) = params.deferred_mcp_tools { + if let Some(deferred_mcp_tools) = context.deferred_mcp_tools { for tool in deferred_mcp_tools { - executors.push(Arc::new(McpHandler::with_exposure( + planned_tools.add_runtime(McpHandler::with_exposure( tool.clone(), ToolExposure::Deferred, - ))); + )); } } +} - for tool in params.dynamic_tools { - let Some(handler) = DynamicToolHandler::new(tool).map(Arc::new) else { +fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + for tool in context.dynamic_tools { + let Some(handler) = DynamicToolHandler::new(tool) else { tracing::error!( "Failed to convert dynamic tool {:?} to OpenAI tool", tool.name @@ -543,23 +665,31 @@ fn collect_tool_executors( continue; }; - executors.push(handler); + planned_tools.add_runtime(handler); } +} - append_extension_tool_executors(config, params.extension_tool_executors, &mut executors); - - executors +fn add_extension_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + // Extension ToolContributor implementations are resolved into executors + // before planning. Core only adapts those executors into its runtime set. + append_extension_tool_executors( + context.turn_context, + context.extension_tool_executors, + planned_tools, + ); } fn append_tool_search_executor( - config: &ToolsConfig, - executors: &mut Vec>, + context: &CoreToolPlanContext<'_>, + planned_tools: &mut PlannedTools, ) { - if !(config.search_tool && config.namespace_tools) { + let turn_context = context.turn_context; + if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) { return; } - let search_infos = executors + let search_infos = planned_tools + .runtimes() .iter() .filter(|executor| executor.exposure() == ToolExposure::Deferred) .filter_map(|executor| executor.search_info()) @@ -568,42 +698,49 @@ fn append_tool_search_executor( return; } - executors.push(Arc::new(ToolSearchHandler::new(search_infos))); + planned_tools.add_runtime(ToolSearchHandler::new(search_infos)); } fn prepend_code_mode_executors( - config: &ToolsConfig, - executors: &mut Vec>, + context: &CoreToolPlanContext<'_>, + planned_tools: &mut PlannedTools, ) { - let deferred_tools_available = config.search_tool - && executors + let turn_context = context.turn_context; + let deferred_tools_available = search_tool_enabled(turn_context) + && planned_tools + .runtimes() .iter() .any(|executor| executor.exposure() == ToolExposure::Deferred); - let code_mode_executors = - build_code_mode_executors(config, executors, deferred_tools_available); - executors.splice(0..0, code_mode_executors); + let code_mode_executors = build_code_mode_executors( + turn_context, + planned_tools.runtimes(), + deferred_tools_available, + ); + planned_tools.runtimes.splice(0..0, code_mode_executors); } fn append_extension_tool_executors( - config: &ToolsConfig, + turn_context: &TurnContext, executors: &[Arc>], - registered_executors: &mut Vec>, + planned_tools: &mut PlannedTools, ) { if executors.is_empty() { return; } - let mut reserved_tool_names = registered_executors + let mut reserved_tool_names = planned_tools + .runtimes() .iter() .map(|executor| executor.tool_name()) .collect::>(); - if config.code_mode_enabled { + if code_mode_enabled(turn_context) { reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } - if config.search_tool - && config.namespace_tools - && registered_executors + if search_tool_enabled(turn_context) + && namespace_tools_enabled(turn_context) + && planned_tools + .runtimes() .iter() .any(|executor| executor.exposure() == ToolExposure::Deferred) { @@ -616,7 +753,7 @@ fn append_extension_tool_executors( warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; } - registered_executors.push(Arc::new(ExtensionToolAdapter::new(executor))); + planned_tools.add_runtime(ExtensionToolAdapter::new(executor)); } } @@ -714,9 +851,6 @@ fn code_mode_namespace_name<'a>( .map(|namespace_description| namespace_description.name.as_str()) } -#[cfg(test)] -#[path = "spec_plan_model_tests.rs"] -mod model_tests; #[cfg(test)] #[path = "spec_plan_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_plan_model_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs deleted file mode 100644 index 1896a73456..0000000000 --- a/codex-rs/core/src/tools/spec_plan_model_tests.rs +++ /dev/null @@ -1,1401 +0,0 @@ -use crate::config::test_config; -use crate::shell::Shell; -use crate::shell::ShellType; -use crate::test_support::construct_model_info_offline; -use crate::tools::ToolRouter; -use crate::tools::router::ToolRouterParams; -use crate::tools::tool_user_shell_type; -use codex_app_server_protocol::AppInfo; -use codex_features::Feature; -use codex_features::Features; -use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; -use codex_mcp::ToolInfo; -use codex_models_manager::bundled_models_response; -use codex_models_manager::model_info::with_config_overrides; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::protocol::SessionSource; -use codex_tools::DiscoverableTool; -use codex_tools::JsonSchema; -use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; -use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ResponsesApiTool; -use codex_tools::ShellCommandBackendConfig; -use codex_tools::TOOL_SEARCH_TOOL_NAME; -use codex_tools::ToolName; -use codex_tools::ToolSpec; -use codex_tools::ToolsConfig; -use codex_tools::ToolsConfigParams; -use codex_tools::UnifiedExecShellMode; -use codex_tools::ZshForkConfig; -use codex_tools::mcp_call_tool_result_output_schema; -use codex_tools::mcp_tool_to_deferred_responses_api_tool; -use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::assert_regex_match; -use pretty_assertions::assert_eq; -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::sync::Arc; - -fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } -} - -fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo { - let (callable_namespace, callable_name) = display_name - .rsplit_once('/') - .map(|(namespace, callable_name)| (format!("{namespace}/"), callable_name.to_string())) - .unwrap_or_else(|| ("".to_string(), display_name.to_string())); - - ToolInfo { - server_name: "test_server".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name, - callable_namespace, - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - -fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { - let slug = name.replace(' ', "-").to_lowercase(); - DiscoverableTool::Connector(Box::new(AppInfo { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })) -} - -async fn search_capable_model_info() -> ModelInfo { - let config = test_config().await; - let mut model_info = construct_model_info_offline("gpt-5.4", &config); - model_info.supports_search_tool = true; - model_info -} - -#[test] -fn deferred_responses_api_tool_serializes_with_defer_loading() { - let tool = mcp_tool( - "lookup_order", - "Look up an order", - serde_json::json!({ - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - }), - ); - - let serialized = serde_json::to_value(ToolSpec::Function( - mcp_tool_to_deferred_responses_api_tool( - &ToolName::namespaced("mcp__codex_apps__", "lookup_order"), - &tool, - ) - .expect("convert deferred tool"), - )) - .expect("serialize deferred tool"); - - assert_eq!( - serialized, - serde_json::json!({ - "type": "function", - "name": "lookup_order", - "description": "Look up an order", - "strict": false, - "defer_loading": true, - "parameters": { - "type": "object", - "properties": { - "order_id": {"type": "string"} - }, - "required": ["order_id"], - "additionalProperties": false, - } - }) - ); -} - -// Avoid order-based assertions; compare via set containment instead. -fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { - use std::collections::HashSet; - let mut names = HashSet::new(); - let mut duplicates = Vec::new(); - for name in tools.iter().map(ToolSpec::name) { - if !names.insert(name) { - duplicates.push(name); - } - } - assert!( - duplicates.is_empty(), - "duplicate tool entries detected: {duplicates:?}" - ); - for expected in expected_subset { - assert!( - names.contains(expected), - "expected tool {expected} to be present; had: {names:?}" - ); - } -} - -fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { - match config.shell_type { - ConfigShellToolType::Default => Some("shell_command"), - ConfigShellToolType::Local => Some("shell_command"), - ConfigShellToolType::UnifiedExec => None, - ConfigShellToolType::Disabled => None, - ConfigShellToolType::ShellCommand => Some("shell_command"), - } -} - -fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { - tools - .iter() - .find(|tool| tool.name() == expected_name) - .unwrap_or_else(|| panic!("expected tool {expected_name}")) -} - -fn find_namespace_function_tool<'a>( - tools: &'a [ToolSpec], - expected_namespace: &str, - expected_name: &str, -) -> &'a ResponsesApiTool { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool), - _ => None, - }) - .unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace")) -} - -async fn multi_agent_v2_tools_config() -> ToolsConfig { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_max_concurrent_threads_per_session(Some(4)) -} - -fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String { - let tools = build_specs( - tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - description.clone() -} - -async fn model_info_from_models_json(slug: &str) -> ModelInfo { - let config = test_config().await; - let response = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = response - .models - .into_iter() - .find(|candidate| candidate.slug == slug) - .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); - with_config_overrides(model, &config.to_models_manager_config()) -} - -/// Builds tool specs and the matching registry from the same executor list. -fn build_specs( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - build_specs_with_inputs_for_test( - config, - mcp_tools, - deferred_mcp_tools, - /*discoverable_tools*/ None, - /*extension_tool_executors*/ &[], - dynamic_tools, - ) -} - -fn build_specs_with_inputs_for_test( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc< - dyn codex_extension_api::ToolExecutor, - >], - dynamic_tools: &[DynamicToolSpec], -) -> Vec { - ToolRouter::from_config( - config, - ToolRouterParams { - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors: extension_tool_executors.to_vec(), - dynamic_tools, - }, - ) - .model_visible_specs() -} - -#[tokio::test] -async fn get_memory_requires_feature_flag() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.disable(Feature::MemoryTool); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert!( - !tools.iter().any(|t| t.name() == "get_memory"), - "get_memory should be disabled when memory_tool feature is off" - ); -} - -async fn assert_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - expected_tools: &[&str], -) { - let _config = test_config().await; - let model_info = model_info_from_models_json(model_slug).await; - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features, - image_generation_tool_auth_allowed: true, - web_search_mode, - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - assert_eq!(&tool_names, &expected_tools); -} - -async fn assert_default_model_tools( - model_slug: &str, - features: &Features, - web_search_mode: Option, - shell_tool: &'static str, - expected_tail: &[&str], -) { - let mut expected = if features.enabled(Feature::UnifiedExec) { - vec!["exec_command", "write_stdin"] - } else { - vec![shell_tool] - }; - expected.extend(expected_tail); - assert_model_tools(model_slug, features, web_search_mode, &expected).await; -} - -#[tokio::test] -async fn test_build_specs_gpt5_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt51_codex_default() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt5_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_gpt51_codex_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_codex_max_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_codex_5_1_mini_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4-mini", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.2", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_defaults() { - let features = Features::with_defaults(); - assert_default_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Cached), - "shell_command", - &[ - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_gpt_5_1_codex_max_unified_exec_web_search() { - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &[ - "exec_command", - "write_stdin", - "update_plan", - "request_user_input", - "apply_patch", - "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "web_search", - "image_generation", - ], - ) - .await; -} - -#[tokio::test] -async fn test_build_specs_default_shell_present() { - let config = test_config().await; - let model_info = construct_model_info_offline("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - Some(Vec::new()), - /*deferred_mcp_tools*/ None, - &[], - ); - - // Only check the shell variant and a couple of core tools. - let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; - if let Some(shell_tool) = shell_tool_name(&tools_config) { - subset.push(shell_tool); - } - assert_contains_tool_names(&tools, &subset); -} - -#[tokio::test] -async fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { - let config = test_config().await; - let model_info = construct_model_info_offline("o3", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ShellZshFork); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let user_shell = Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), - }; - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); - assert_eq!( - tools_config.shell_command_backend, - ShellCommandBackendConfig::ZshFork - ); - assert_eq!( - tools_config.unified_exec_shell_mode, - UnifiedExecShellMode::Direct - ); - assert_eq!( - tools_config - .with_unified_exec_shell_mode_for_session( - tool_user_shell_type(&user_shell), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })), - ) - .unified_exec_shell_mode, - if cfg!(unix) { - UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(), - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path( - "/opt/codex/codex-execve-wrapper", - ) - .unwrap(), - }) - } else { - UnifiedExecShellMode::Direct - } - ); -} - -#[tokio::test] -async fn spawn_agent_description_omits_usage_hint_when_disabled() { - let tools_config = multi_agent_v2_tools_config() - .await - .with_spawn_agent_usage_hint(/*spawn_agent_usage_hint*/ false); - let description = multi_agent_v2_spawn_agent_description(&tools_config); - - assert_regex_match( - r#"(?sx) - ^\s* - No\ picker-visible\ model\ overrides\ are\ currently\ loaded\. - \s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\. - \s+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`\. - \s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\. - \s+Spawned\ agents\ inherit\ your\ current\ model\ by\ default\.\ Omit\ `model`\ to\ use\ that\ preferred\ default;\ set\ `model`\ only\ when\ an\ explicit\ override\ is\ needed\. - \s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\. - \s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\. - \s+This\ session\ is\ configured\ with\ `max_concurrent_threads_per_session\ =\ 4`\ for\ concurrently\ open\ agent\ threads\. - \s*$ - "#, - &description, - ); -} - -#[tokio::test] -async fn spawn_agent_description_uses_configured_usage_hint_text() { - let tools_config = multi_agent_v2_tools_config() - .await - .with_spawn_agent_usage_hint_text(Some( - /*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(), - )); - let description = multi_agent_v2_spawn_agent_description(&tools_config); - - assert_regex_match( - r#"(?sx) - ^\s* - No\ picker-visible\ model\ overrides\ are\ currently\ loaded\. - \s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\. - \s+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`\. - \s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\. - \s+Spawned\ agents\ inherit\ your\ current\ model\ by\ default\.\ Omit\ `model`\ to\ use\ that\ preferred\ default;\ set\ `model`\ only\ when\ an\ explicit\ override\ is\ needed\. - \s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\. - \s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\. - \s+This\ session\ is\ configured\ with\ `max_concurrent_threads_per_session\ =\ 4`\ for\ concurrently\ open\ agent\ threads\. - \s+Custom\ delegation\ guidance\ only\. - \s*$ - "#, - &description, - ); -} - -#[tokio::test] -async fn multi_agent_v2_wait_agent_schema_uses_configured_timeouts() { - let wait_agent_min_timeout_ms = Some(20_000); - let wait_agent_max_timeout_ms = Some(120_000); - let wait_agent_default_timeout_ms = Some(60_000); - let tools_config = multi_agent_v2_tools_config() - .await - .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms) - .with_wait_agent_max_timeout_ms(wait_agent_max_timeout_ms) - .with_wait_agent_default_timeout_ms(wait_agent_default_timeout_ms); - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let wait_agent = find_tool(&tools, "wait_agent"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = wait_agent else { - panic!("wait_agent should be a function tool"); - }; - let timeout_description = parameters - .properties - .as_ref() - .and_then(|properties| properties.get("timeout_ms")) - .and_then(|schema| schema.description.as_deref()); - - assert_eq!( - timeout_description, - Some("Optional timeout in milliseconds. Defaults to 60000, min 20000, max 120000.") - ); -} - -#[tokio::test] -async fn request_plugin_install_requires_apps_and_plugins_features() { - let model_info = search_capable_model_info().await; - let discoverable_tools = Some(vec![discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - )]); - let available_models = Vec::new(); - - for disabled_feature in [Feature::Apps, Feature::Plugins] { - let mut features = Features::with_defaults(); - features.enable(Feature::ToolSuggest); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); - features.disable(disabled_feature); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - discoverable_tools.clone(), - /*extension_tool_executors*/ &[], - &[], - ); - - assert!( - !tools - .iter() - .any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME), - "tool_suggest should be absent when {disabled_feature:?} is disabled" - ); - } -} - -#[tokio::test] -async fn search_tool_is_hidden_without_deferred_tools() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(Vec::new()), - &[], - ); - assert!( - !tools - .iter() - .any(|tool| tool.name() == TOOL_SEARCH_TOOL_NAME) - ); -} - -#[tokio::test] -async fn search_tool_description_falls_back_to_connector_name_without_description() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_create_event".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar_create_event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }]), - &[], - ); - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - - assert!(description.contains("- Calendar")); - assert!(!description.contains("- Calendar:")); -} - -#[tokio::test] -async fn test_mcp_tool_property_missing_type_defaults_to_empty_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/search", - mcp_tool( - "search", - "Search docs", - serde_json::json!({ - "type": "object", - "properties": { - "query": {"description": "search query"} - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "search"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "search".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([("query".to_string(), JsonSchema::default())]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Search docs".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_preserves_integer_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/paginate", - mcp_tool( - "paginate", - "Pagination", - serde_json::json!({ - "type": "object", - "properties": {"page": {"type": "integer"}} - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "paginate"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "paginate".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "page".to_string(), - JsonSchema::integer(/*description*/ None), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Pagination".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_array_without_items_gets_default_string_items() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/tags", - mcp_tool( - "tags", - "Tags", - serde_json::json!({ - "type": "object", - "properties": {"tags": {"type": "array"}} - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "tags"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "tags".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "tags".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - /*description*/ None, - ), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Tags".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_mcp_tool_anyof_defaults_to_string() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "dash/value", - mcp_tool( - "value", - "AnyOf Value", - serde_json::json!({ - "type": "object", - "properties": { - "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "dash/", "value"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "value".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([( - "value".to_string(), - JsonSchema::any_of( - vec![ - JsonSchema::string(/*description*/ None), - JsonSchema::number(/*description*/ None), - ], - /*description*/ None, - ), - )]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "AnyOf Value".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let tools = build_specs( - &tools_config, - Some(vec![mcp_tool_info_with_display_name( - "test_server/do_something_cool", - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": {"type": "string"}, - "number_argument": {"type": "number"}, - "object_argument": { - "type": "object", - "properties": { - "string_property": {"type": "string"}, - "number_property": {"type": "number"} - }, - "required": ["string_property", "number_property"], - "additionalProperties": { - "type": "object", - "properties": { - "addtl_prop": {"type": "string"} - }, - "required": ["addtl_prop"], - "additionalProperties": false - } - } - } - }), - ), - )]), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); - assert_eq!( - *tool, - ResponsesApiTool { - name: "do_something_cool".to_string(), - parameters: JsonSchema::object( - /*properties*/ - BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_argument".to_string(), - JsonSchema::number(/*description*/ None), - ), - ( - "object_argument".to_string(), - JsonSchema::object( - BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_property".to_string(), - JsonSchema::number(/*description*/ None), - ), - ]), - Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - Some( - JsonSchema::object( - BTreeMap::from([( - "addtl_prop".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["addtl_prop".to_string()]), - Some(false.into()), - ) - .into(), - ), - ), - ), - ]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[tokio::test] -async fn code_mode_only_restricts_model_tools_to_exec_tools() { - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - - assert_model_tools( - "gpt-5.4", - &features, - Some(WebSearchMode::Live), - &["exec", "wait"], - ) - .await; -} - -#[tokio::test] -async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - - assert_eq!( - tool_names, - vec![ - "exec", - "wait", - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ] - ); - - let exec = find_tool(&model_visible_specs, "exec"); - let ToolSpec::Freeform(exec) = exec else { - panic!("exec should be a freeform tool"); - }; - assert!(!exec.description.contains("spawn_agent")); - assert!(!exec.description.contains("wait_agent")); - assert!( - !exec - .description - .contains("do not attempt to use any other tools directly") - ); - - let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); - let ToolSpec::Function(spawn_agent) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - assert!(!spawn_agent.description.contains("exec tool declaration")); -} - -#[tokio::test] -async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())) - .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); - let router = ToolRouter::from_config( - &tools_config, - ToolRouterParams { - mcp_tools: None, - deferred_mcp_tools: None, - discoverable_tools: None, - extension_tool_executors: Vec::new(), - dynamic_tools: &[], - }, - ); - let model_visible_specs = router.model_visible_specs(); - let tool_names = model_visible_specs - .iter() - .map(ToolSpec::name) - .collect::>(); - - assert_eq!(tool_names, vec!["exec", "wait", "agents"]); - - let exec = find_tool(&model_visible_specs, "exec"); - let ToolSpec::Freeform(exec) = exec else { - panic!("exec should be a freeform tool"); - }; - assert!(!exec.description.contains("spawn_agent")); - assert!(!exec.description.contains("wait_agent")); - assert!( - !exec - .description - .contains("do not attempt to use any other tools directly") - ); - - for tool_name in [ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ] { - let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name); - assert!(!tool.description.contains("exec tool declaration")); - } -} diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1b64093ad5..3c7c361e88 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1,607 +1,669 @@ -use super::*; -use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; -use crate::tools::handlers::goal_spec::create_create_goal_tool; -use crate::tools::handlers::goal_spec::create_get_goal_tool; -use crate::tools::handlers::goal_spec::create_update_goal_tool; -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; -use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool; -use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_send_message_tool; -use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; -use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1; -use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2; -use crate::tools::handlers::plan_spec::create_update_plan_tool; -use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME; -use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool; -use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description; -use crate::tools::handlers::shell_spec::CommandToolOptions; -use crate::tools::handlers::shell_spec::create_exec_command_tool; -use crate::tools::handlers::shell_spec::create_request_permissions_tool; -use crate::tools::handlers::shell_spec::create_write_stdin_tool; -use crate::tools::handlers::shell_spec::request_permissions_tool_description; -use crate::tools::handlers::view_image_spec::ViewImageToolOptions; -use crate::tools::handlers::view_image_spec::create_view_image_tool; -use crate::tools::registry::ToolRegistry; -use codex_app_server_protocol::AppInfo; -use codex_extension_api::ToolCall as ExtensionToolCall; -use codex_extension_api::ToolExecutor; +use std::collections::BTreeMap; +use std::sync::Arc; + use codex_features::Feature; -use codex_features::Features; +use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_mcp::ToolInfo; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::WebSearchConfig; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; +use codex_protocol::openai_models::ApplyPatchToolType; +use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_tools::AdditionalProperties; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; -use codex_tools::FreeformTool; -use codex_tools::JsonSchema; -use codex_tools::JsonSchemaPrimitiveType; -use codex_tools::JsonSchemaType; -use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ResponsesApiTool; -use codex_tools::ResponsesApiWebSearchFilters; -use codex_tools::ResponsesApiWebSearchUserLocation; -use codex_tools::TOOL_SEARCH_TOOL_NAME; -use codex_tools::ToolEnvironmentMode; +use codex_tools::ToolExposure; use codex_tools::ToolName; -use codex_tools::ToolsConfigParams; -use codex_tools::mcp_call_tool_result_output_schema; -use codex_tools::request_user_input_available_modes; +use codex_tools::ToolSpec; use pretty_assertions::assert_eq; use serde_json::json; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::sync::Arc; -const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; -const DEFAULT_AGENT_TYPE_DESCRIPTION: &str = "Test agent type description."; -const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000; +use crate::session::tests::make_session_and_context; +use crate::session::turn_context::TurnContext; +use crate::tools::router::ToolRouter; +use crate::tools::router::ToolRouterParams; -fn extension_tool_executor( - name: &str, - description: &str, -) -> Arc> { - struct SpecOnlyExtensionExecutor { - name: String, - description: String, - } - - #[async_trait::async_trait] - impl ToolExecutor for SpecOnlyExtensionExecutor { - fn tool_name(&self) -> ToolName { - ToolName::plain(self.name.as_str()) - } - - fn spec(&self) -> Option { - Some(ToolSpec::Function(ResponsesApiTool { - name: self.name.clone(), - description: self.description.clone(), - strict: true, - parameters: JsonSchema::object( - BTreeMap::from([( - "message".to_string(), - JsonSchema::string(/*description*/ None), - )]), - Some(vec!["message".to_string()]), - Some(false.into()), - ), - output_schema: None, - defer_loading: None, - })) - } - - async fn handle( - &self, - _call: ExtensionToolCall, - ) -> Result, codex_tools::FunctionCallError> { - panic!("spec planning should not execute extension tools") - } - } - - Arc::new(SpecOnlyExtensionExecutor { - name: name.to_string(), - description: description.to_string(), - }) +#[derive(Default)] +struct ToolPlanInputs { + mcp_tools: Option>, + deferred_mcp_tools: Option>, + discoverable_tools: Option>, + dynamic_tools: Vec, } -#[test] -fn extension_tools_do_not_replace_builtin_tools() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let extension_tool_executors = vec![extension_tool_executor( - "update_plan", - "Extension attempt to replace a built-in tool.", - )]; - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - /*discoverable_tools*/ None, - &extension_tool_executors, - &[], - ); +struct ToolPlanProbe { + visible_specs: Vec, + visible_names: Vec, + namespace_functions: BTreeMap>, + registered_names: Vec, + exposures: BTreeMap, +} - assert_eq!( - find_tool(&tools, "update_plan").clone(), - create_update_plan_tool() - ); - assert_eq!( - tools +impl ToolPlanProbe { + fn from_router(router: ToolRouter) -> Self { + let visible_specs = router.model_visible_specs(); + let visible_names = visible_specs .iter() - .filter(|tool| tool.name() == "update_plan") - .count(), - 1 - ); -} + .map(|spec| spec.name().to_string()) + .collect::>(); + let namespace_functions = visible_specs + .iter() + .filter_map(|spec| match spec { + ToolSpec::Namespace(namespace) => Some(( + namespace.name.clone(), + namespace + .tools + .iter() + .map(|tool| match tool { + ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), + }) + .collect::>(), + )), + ToolSpec::Function(_) + | ToolSpec::ToolSearch { .. } + | ToolSpec::ImageGeneration { .. } + | ToolSpec::WebSearch { .. } + | ToolSpec::Freeform(_) => None, + }) + .collect::>(); + let registered_tool_names = router.registered_tool_names_for_test(); + let registered_names = registered_tool_names + .iter() + .map(ToString::to_string) + .collect::>(); + let exposures = registered_tool_names + .iter() + .filter_map(|name| { + router + .tool_exposure_for_test(name) + .map(|exposure| (name.to_string(), exposure)) + }) + .collect::>(); -#[test] -fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let mut actual = BTreeMap::new(); - let mut duplicate_names = Vec::new(); - for tool in &tools { - let name = tool.name().to_string(); - if actual.insert(name.clone(), tool.clone()).is_some() { - duplicate_names.push(name); + Self { + visible_specs, + visible_names, + namespace_functions, + registered_names, + exposures, } } - assert!( - duplicate_names.is_empty(), - "duplicate tool entries detected: {duplicate_names:?}" - ); - let mut expected = BTreeMap::new(); - for spec in [ - create_exec_command_tool(CommandToolOptions { - allow_login_shell: true, - exec_permission_approvals_enabled: false, - }), - create_write_stdin_tool(), - create_update_plan_tool(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)), - create_apply_patch_freeform_tool(/*include_environment_id*/ false), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, + fn assert_visible_contains(&self, expected: &[&str]) { + for name in expected { + assert!( + self.visible_names.iter().any(|visible| visible == name), + "expected visible tool `{name}` in {:?}", + self.visible_names + ); + } + } + + fn assert_visible_lacks(&self, expected_absent: &[&str]) { + for name in expected_absent { + assert!( + !self.visible_names.iter().any(|visible| visible == name), + "expected visible tool `{name}` to be absent from {:?}", + self.visible_names + ); + } + } + + fn assert_registered_contains(&self, expected: &[&str]) { + for name in expected { + assert!( + self.registered_names + .iter() + .any(|registered| registered == name), + "expected registered tool `{name}` in {:?}", + self.registered_names + ); + } + } + + fn assert_registered_lacks(&self, expected_absent: &[&str]) { + for name in expected_absent { + assert!( + !self + .registered_names + .iter() + .any(|registered| registered == name), + "expected registered tool `{name}` to be absent from {:?}", + self.registered_names + ); + } + } + + fn namespace_function_names(&self, namespace: &str) -> &[String] { + self.namespace_functions + .get(namespace) + .map_or(&[], Vec::as_slice) + } + + fn visible_spec(&self, name: &str) -> &ToolSpec { + self.visible_specs + .iter() + .find(|spec| spec.name() == name) + .unwrap_or_else(|| panic!("expected visible spec `{name}` in {:?}", self.visible_names)) + } + + fn exposure(&self, name: &str) -> ToolExposure { + *self + .exposures + .get(name) + .unwrap_or_else(|| panic!("expected registered tool `{name}`")) + } +} + +async fn probe_with( + configure_turn: impl FnOnce(&mut TurnContext), + inputs: ToolPlanInputs, +) -> ToolPlanProbe { + let (_session, mut turn) = make_session_and_context().await; + configure_turn(&mut turn); + let router = ToolRouter::from_turn_context( + &turn, + ToolRouterParams { + mcp_tools: inputs.mcp_tools, + deferred_mcp_tools: inputs.deferred_mcp_tools, + discoverable_tools: inputs.discoverable_tools, + extension_tool_executors: Vec::new(), + dynamic_tools: inputs.dynamic_tools.as_slice(), }, - create_image_generation_tool("png"), - create_view_image_tool(ViewImageToolOptions { - can_request_original_image_detail: config.can_request_original_image_detail, - include_environment_id: false, - }), - ] { - expected.insert(spec.name().to_string(), spec); - } - if config.goal_tools { - for spec in [ - create_get_goal_tool(), - create_create_goal_tool(), - create_update_goal_tool(), - ] { - expected.insert(spec.name().to_string(), spec); - } - } - let collab_specs = if config.multi_agent_v2 { - vec![ - create_spawn_agent_tool_v2(spawn_agent_tool_options(&config)), - create_send_message_tool(), - create_wait_agent_tool_v2(wait_agent_timeout_options()), - create_close_agent_tool_v2(), - ] + ); + ToolPlanProbe::from_router(router) +} + +async fn probe(configure_turn: impl FnOnce(&mut TurnContext)) -> ToolPlanProbe { + probe_with(configure_turn, ToolPlanInputs::default()).await +} + +fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { + if enabled { + turn.features + .enable(feature) + .expect("test feature should be enableable"); } else { - vec![ - create_spawn_agent_tool_v1(spawn_agent_tool_options(&config)), - create_send_input_tool_v1(), - create_wait_agent_tool_v1(wait_agent_timeout_options()), - create_close_agent_tool_v1(), - ] - }; - for spec in collab_specs { - expected.insert(spec.name().to_string(), spec); - } - if !config.multi_agent_v2 { - let spec = create_resume_agent_tool(); - expected.insert(spec.name().to_string(), spec); + turn.features + .disable(feature) + .expect("test feature should be disableable"); } - if config.exec_permission_approvals_enabled { - let spec = create_request_permissions_tool(request_permissions_tool_description()); - expected.insert(spec.name().to_string(), spec); + let mut config = (*turn.config).clone(); + if enabled { + config + .features + .enable(feature) + .expect("test feature should be enableable in config"); + } else { + config + .features + .disable(feature) + .expect("test feature should be disableable in config"); } + turn.config = Arc::new(config); +} - assert_eq!( - actual.keys().collect::>(), - expected.keys().collect::>(), - "tool name set mismatch" - ); - - for name in expected.keys() { - let mut actual_spec = actual.get(name).expect("present").clone(); - let mut expected_spec = expected.get(name).expect("present").clone(); - strip_descriptions_tool(&mut actual_spec); - strip_descriptions_tool(&mut expected_spec); - assert_eq!(actual_spec, expected_spec, "spec mismatch for {name}"); +fn set_features(turn: &mut TurnContext, features: &[Feature]) { + for feature in features { + set_feature(turn, *feature, /*enabled*/ true); } } -#[test] -fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn update_config(turn: &mut TurnContext, update: impl FnOnce(&mut crate::config::Config)) { + let mut config = (*turn.config).clone(); + update(&mut config); + turn.config = Arc::new(config); +} + +fn set_web_search_mode(turn: &mut TurnContext, mode: WebSearchMode) { + update_config(turn, |config| { + config + .web_search_mode + .set(mode) + .expect("test web search mode should be accepted"); }); +} - let (single_environment_tools, _) = build_specs( - &config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &single_environment_tools, - "exec_command", - /*expected_present*/ false, - ); - - let multi_environment_config = config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &multi_environment_tools, - "exec_command", - /*expected_present*/ true, +fn use_chatgpt_auth(turn: &mut TurnContext) { + turn.auth_manager = Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + turn.provider = create_model_provider( + turn.config.model_provider.clone(), + turn.auth_manager.clone(), ); } -#[test] -fn apply_patch_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn use_bedrock_provider(turn: &mut TurnContext) { + let provider_info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + update_config(turn, |config| { + config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + config.model_provider = provider_info.clone(); }); - - let (single_environment_tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_apply_patch_environment_id(&single_environment_tools, /*expected_present*/ false); - - let multi_environment_config = - tools_config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_apply_patch_environment_id(&multi_environment_tools, /*expected_present*/ true); + turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } -#[test] -fn test_build_specs_collab_tools_enabled() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &["spawn_agent", "send_input", "wait_agent", "close_agent"], - ); - assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); - assert_lacks_tool_name(&tools, "list_agents"); - - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = spawn_agent else { - panic!("spawn_agent should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(properties.contains_key("fork_context")); - assert!(!properties.contains_key("fork_turns")); +fn duplicate_primary_environment(turn: &mut TurnContext) { + let mut second_environment = turn.environments.turn_environments[0].clone(); + second_environment.environment_id = "secondary".to_string(); + turn.environments.turn_environments.push(second_environment); } -#[test] -fn goal_tools_require_goals_feature() { - let model_info = model_info(); - let available_models = Vec::new(); - let mut features = Features::with_defaults(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_lacks_tool_name(&tools, "get_goal"); - assert_lacks_tool_name(&tools, "create_goal"); - assert_lacks_tool_name(&tools, "update_goal"); - - features.enable(Feature::Goals); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_contains_tool_names(&tools, &["get_goal", "create_goal", "update_goal"]); +fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { + ToolInfo { + server_name: server.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: name.to_string(), + callable_namespace: namespace.to_string(), + namespace_description: Some(format!("Tools from {server}.")), + tool: rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(format!("{name} test tool").into()), + input_schema: Arc::new(rmcp::model::object(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }))), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } } -#[test] -fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ], - ); - - let spawn_agent = find_tool(&tools, "spawn_agent"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = spawn_agent - else { - panic!("spawn_agent should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("task_name")); - assert!(properties.contains_key("message")); - assert!(properties.contains_key("fork_turns")); - assert!(!properties.contains_key("items")); - assert!(!properties.contains_key("fork_context")); - assert_eq!( - required, - Some(&vec!["task_name".to_string(), "message".to_string()]) - ); - let output_schema = output_schema - .as_ref() - .expect("spawn_agent should define output schema"); - assert_eq!(output_schema["required"], json!(["task_name", "nickname"])); - - let send_message = find_tool(&tools, "send_message"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = send_message - else { - panic!("send_message should be a function tool"); - }; - assert_eq!(output_schema, &None); - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("target")); - assert!(!properties.contains_key("interrupt")); - assert!(properties.contains_key("message")); - assert!(!properties.contains_key("items")); - assert_eq!( - required, - Some(&vec!["target".to_string(), "message".to_string()]) - ); - - let followup_task = find_tool(&tools, "followup_task"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = followup_task - else { - panic!("followup_task should be a function tool"); - }; - assert_eq!(output_schema, &None); - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("target")); - assert!(properties.contains_key("message")); - assert!(!properties.contains_key("items")); - assert_eq!( - required, - Some(&vec!["target".to_string(), "message".to_string()]) - ); - - let wait_agent = find_tool(&tools, "wait_agent"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = wait_agent - else { - panic!("wait_agent should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(!properties.contains_key("targets")); - assert!(properties.contains_key("timeout_ms")); - assert_eq!(required, None); - let output_schema = output_schema - .as_ref() - .expect("wait_agent should define output schema"); - assert_eq!( - output_schema["properties"]["message"]["description"], - json!("Brief wait summary without the agent's final content.") - ); - - let list_agents = find_tool(&tools, "list_agents"); - let ToolSpec::Function(ResponsesApiTool { - parameters, - output_schema, - .. - }) = list_agents - else { - panic!("list_agents should be a function tool"); - }; - let (properties, required) = expect_object_schema(parameters); - assert!(properties.contains_key("path_prefix")); - assert_eq!(required, None); - let output_schema = output_schema - .as_ref() - .expect("list_agents should define output schema"); - assert_eq!( - output_schema["properties"]["agents"]["items"]["required"], - json!(["agent_name", "agent_status", "last_task_message"]) - ); - assert_lacks_tool_name(&tools, "send_input"); - assert_lacks_tool_name(&tools, "resume_agent"); +fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec { + DynamicToolSpec { + namespace: namespace.map(str::to_string), + name: name.to_string(), + description: format!("{name} dynamic tool"), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading, + } } -#[test] -fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool { + DiscoverablePluginInfo { + id: id.to_string(), + name: name.to_string(), + description: Some(format!("{name} plugin")), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + .into() +} + +fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool { + serde_json::to_value(spec) + .expect("tool spec should serialize") + .pointer(&format!("/parameters/properties/{parameter_name}")) + .is_some() +} + +fn apply_patch_accepts_environment_id(spec: &ToolSpec) -> bool { + match spec { + ToolSpec::Freeform(tool) if tool.name == "apply_patch" => { + tool.format.definition.contains("Environment ID") + } + _ => false, + } +} + +#[tokio::test] +async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() { + let plan = probe(|turn| { + set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); + set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); + turn.model_info.shell_type = ConfigShellToolType::ShellCommand; }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], + .await; + + plan.assert_visible_contains(&["exec_command", "write_stdin"]); + plan.assert_visible_lacks(&["shell_command"]); + plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]); + assert_eq!(plan.exposure("shell_command"), ToolExposure::Direct); +} + +#[tokio::test] +async fn environment_count_controls_environment_backed_tools() { + let no_environment = probe(|turn| { + turn.environments.turn_environments.clear(); + set_feature(turn, Feature::ShellTool, /*enabled*/ true); + turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + }) + .await; + no_environment.assert_visible_lacks(&[ + "shell_command", + "exec_command", + "apply_patch", + "view_image", + ]); + no_environment.assert_registered_lacks(&[ + "shell_command", + "exec_command", + "apply_patch", + "view_image", + ]); + + let multiple_environments = probe(|turn| { + duplicate_primary_environment(turn); + set_feature(turn, Feature::ShellTool, /*enabled*/ true); + set_feature(turn, Feature::UnifiedExec, /*enabled*/ true); + turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform); + }) + .await; + multiple_environments.assert_visible_contains(&["exec_command", "apply_patch", "view_image"]); + assert!(has_parameter( + multiple_environments.visible_spec("exec_command"), + "environment_id" + )); + assert!(apply_patch_accepts_environment_id( + multiple_environments.visible_spec("apply_patch") + )); + assert!(has_parameter( + multiple_environments.visible_spec("view_image"), + "environment_id" + )); +} + +#[tokio::test] +async fn host_context_gates_goal_and_agent_job_tools() { + let feature_disabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ false); + turn.goal_tools_supported = true; + }) + .await; + feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let host_disabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = false; + }) + .await; + host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let enabled = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = true; + }) + .await; + enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]); + + let review_thread = probe(|turn| { + set_feature(turn, Feature::Goals, /*enabled*/ true); + turn.goal_tools_supported = true; + turn.session_source = SessionSource::SubAgent(SubAgentSource::Review); + }) + .await; + review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); + + let normal_agent_job = probe(|turn| { + set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); + }) + .await; + normal_agent_job.assert_visible_contains(&["spawn_agents_on_csv"]); + normal_agent_job.assert_visible_lacks(&["report_agent_job_result"]); + + let worker_agent_job = probe(|turn| { + set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); + turn.session_source = + SessionSource::SubAgent(SubAgentSource::Other("agent_job:42".to_string())); + }) + .await; + worker_agent_job.assert_visible_contains(&["spawn_agents_on_csv", "report_agent_job_result"]); +} + +#[tokio::test] +async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { + let direct_mcp = probe_with( + |_| {}, + ToolPlanInputs { + mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct__", "lookup")]), + ..ToolPlanInputs::default() + }, + ) + .await; + direct_mcp.assert_visible_contains(&[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ]); + assert_eq!( + direct_mcp.namespace_function_names("mcp__direct__"), + &["lookup".to_string()] ); - assert_contains_tool_names(&tools, &["agents"]); + let searchable_mcp = ToolPlanInputs { + deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable__", "lookup")]), + ..ToolPlanInputs::default() + }; + + let missing_model_capability = probe_with( + |turn| { + turn.model_info.supports_search_tool = false; + }, + ToolPlanInputs { + deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + missing_model_capability.assert_visible_lacks(&["tool_search"]); + + let missing_deferred_tools = probe(|turn| { + turn.model_info.supports_search_tool = true; + }) + .await; + missing_deferred_tools.assert_visible_lacks(&["tool_search"]); + missing_deferred_tools.assert_visible_lacks(&[ + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + ]); + + let missing_namespace_capability = probe_with( + |turn| { + turn.model_info.supports_search_tool = true; + use_bedrock_provider(turn); + }, + ToolPlanInputs { + deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + missing_namespace_capability.assert_visible_lacks(&["tool_search"]); + + let enabled = probe_with( + |turn| { + turn.model_info.supports_search_tool = true; + }, + searchable_mcp, + ) + .await; + enabled.assert_visible_contains(&["tool_search"]); + enabled.assert_registered_contains(&["tool_search", "mcp__searchable__lookup"]); +} + +#[tokio::test] +async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() { + let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]); + for disabled_feature in [Feature::ToolSuggest, Feature::Apps, Feature::Plugins] { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + set_feature(turn, disabled_feature, /*enabled*/ false); + }, + ToolPlanInputs { + discoverable_tools: discoverable_tools.clone(), + ..ToolPlanInputs::default() + }, + ) + .await; + plan.assert_visible_lacks(&["request_plugin_install"]); + } + + let no_candidates = probe(|turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }) + .await; + no_candidates.assert_visible_lacks(&["request_plugin_install"]); + + let enabled = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools, + ..ToolPlanInputs::default() + }, + ) + .await; + enabled.assert_visible_contains(&["request_plugin_install"]); +} + +#[tokio::test] +async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { + let input = ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("codex_app"), + "lookup", + /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }; + let plain = probe_with(|_| {}, input).await; + assert_eq!( + plain.namespace_function_names("codex_app"), + &["lookup".to_string()] + ); + plain.assert_visible_lacks(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + ]); + + let code_mode_only = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("codex_app"), + "lookup", + /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + code_mode_only.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + ]); + assert_eq!( + code_mode_only.namespace_function_names("codex_app"), + Vec::::new().as_slice() + ); +} + +#[tokio::test] +async fn multi_agent_feature_selects_one_agent_tool_family() { + let v1 = probe(|turn| { + set_feature(turn, Feature::Collab, /*enabled*/ true); + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); + }) + .await; + v1.assert_visible_contains(&[ + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ]); + v1.assert_visible_lacks(&["send_message", "followup_task", "list_agents"]); + + let v2 = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.max_concurrent_threads_per_session = 17; + }); + }) + .await; + v2.assert_visible_contains(&[ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ]); + v2.assert_visible_lacks(&["send_input", "resume_agent"]); + let spawn_agent_description = match v2.visible_spec("spawn_agent") { + ToolSpec::Function(tool) => tool.description.as_str(), + other => panic!("expected spawn_agent function spec, got {other:?}"), + }; + assert!(spawn_agent_description.contains("max_concurrent_threads_per_session = 17")); + + let direct_model_only = probe(|turn| { + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::MultiAgentV2, + ], + ); + update_config(turn, |config| { + config.multi_agent_v2.non_code_mode_only = true; + }); + }) + .await; + direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]); + assert_eq!( + direct_model_only.exposure("spawn_agent"), + ToolExposure::DirectModelOnly + ); +} + +#[tokio::test] +async fn multi_agent_v2_can_use_configured_tool_namespace() { + let namespaced = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + }) + .await; + + namespaced.assert_visible_contains(&["agents"]); for tool_name in [ "spawn_agent", "send_message", @@ -610,663 +672,114 @@ fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() { "close_agent", "list_agents", ] { - assert_lacks_tool_name(&tools, tool_name); - assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name))); - assert!(!registry.has_tool(&ToolName::plain(tool_name))); - assert_namespace_contains_function(&tools, "agents", tool_name); + namespaced.assert_visible_lacks(&[tool_name]); + assert!( + namespaced + .registered_names + .contains(&ToolName::namespaced("agents", tool_name).to_string()), + "expected namespaced runtime for {tool_name}" + ); + assert!( + !namespaced + .registered_names + .contains(&ToolName::plain(tool_name).to_string()), + "expected no plain runtime for {tool_name}" + ); + assert!( + namespaced + .namespace_function_names("agents") + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in agents namespace" + ); } } -#[test] -fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::MultiAgentV2); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +#[tokio::test] +async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + use_bedrock_provider(turn); }) - .with_multi_agent_v2_tool_namespace(Some("agents".to_string())); - tools_config.namespace_tools = false; - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + .await; - assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]); - assert_lacks_tool_name(&tools, "agents"); - assert!(registry.has_tool(&ToolName::plain("spawn_agent"))); - assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent"))); -} - -#[test] -fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.disable(Feature::Collab); - features.enable(Feature::MultiAgentV2); - assert!(!features.enabled(Feature::Collab)); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "close_agent", - "list_agents", - ], - ); - assert_lacks_tool_name(&tools, "send_input"); - assert_lacks_tool_name(&tools, "resume_agent"); -} - -#[test] -fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "wait_agent", - "close_agent", - "spawn_agents_on_csv", - ], - ); -} - -#[test] -fn view_image_tool_omits_detail_without_original_detail_support() { - let mut model_info = model_info(); - model_info.supports_image_detail_original = false; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = view_image else { - panic!("view_image should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(!properties.contains_key("detail")); -} - -#[test] -fn view_image_tool_includes_detail_with_original_detail_support() { - let mut model_info = model_info(); - model_info.supports_image_detail_original = true; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = view_image else { - panic!("view_image should be a function tool"); - }; - let (properties, _) = expect_object_schema(parameters); - assert!(properties.contains_key("detail")); - let detail_schema = properties - .get("detail") - .expect("view_image detail should include a description"); - let description = expect_string_description(detail_schema); - let expected = vec![json!("high"), json!("original")]; - assert_eq!(detail_schema.enum_values.as_ref(), Some(&expected)); - assert!(description.contains("Supported values are `high` and `original`")); - assert!(description.contains("omit this field for default high resized behavior")); -} - -#[test] -fn disabled_environment_omits_environment_backed_tools() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }) - .with_environment_mode(ToolEnvironmentMode::None); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "exec_command"); - assert_lacks_tool_name(&tools, "write_stdin"); - assert_lacks_tool_name(&tools, "apply_patch"); - assert_lacks_tool_name(&tools, VIEW_IMAGE_TOOL_NAME); -} - -#[test] -fn view_image_spec_includes_environment_id_only_for_multiple_selected_environments() { - let model_info = model_info(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &Features::with_defaults(), - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (single_environment_tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &single_environment_tools, - VIEW_IMAGE_TOOL_NAME, - /*expected_present*/ false, - ); - - let multi_environment_config = - tools_config.with_environment_mode(ToolEnvironmentMode::Multiple); - let (multi_environment_tools, _) = build_specs( - &multi_environment_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_process_tool_environment_id( - &multi_environment_tools, - VIEW_IMAGE_TOOL_NAME, - /*expected_present*/ true, - ); -} - -#[test] -fn test_build_specs_agent_job_worker_tools_enabled() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::SpawnCsv); - features.normalize_dependencies(); - features.enable(Feature::Sqlite); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::SubAgent(SubAgentSource::Other( - "agent_job:test".to_string(), - )), - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - "spawn_agents_on_csv", - "report_agent_job_result", - REQUEST_USER_INPUT_TOOL_NAME, - ], - ); -} - -#[test] -fn request_user_input_description_reflects_default_mode_feature_flag() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); - assert_eq!( - request_user_input_tool.clone(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)) - ); - - features.enable(Feature::DefaultModeRequestUserInput); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); - assert_eq!( - request_user_input_tool.clone(), - request_user_input_tool_spec(&request_user_input_available_modes(&features)) - ); -} - -#[test] -fn request_permissions_requires_feature_flag() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_lacks_tool_name(&tools, "request_permissions"); - - let mut features = Features::with_defaults(); - features.enable(Feature::RequestPermissionsTool); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let request_permissions_tool = find_tool(&tools, "request_permissions"); - assert_eq!( - request_permissions_tool.clone(), - create_request_permissions_tool(request_permissions_tool_description()) - ); -} - -#[test] -fn request_permissions_tool_is_independent_from_additional_permissions() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::ExecPermissionApprovals); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "request_permissions"); -} - -#[test] -fn image_generation_tools_require_feature_and_supported_model() { - let supported_model_info = model_info(); - let mut unsupported_model_info = supported_model_info.clone(); - unsupported_model_info.input_modalities = vec![InputModality::Text]; - let mut image_generation_disabled_features = Features::with_defaults(); - image_generation_disabled_features.disable(Feature::ImageGeneration); - let mut image_generation_features = Features::with_defaults(); - image_generation_features.enable(Feature::ImageGeneration); - - let available_models = Vec::new(); - let default_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_disabled_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (default_tools, _) = build_specs( - &default_tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], + plan.assert_visible_contains(&["spawn_agent", "send_message", "list_agents"]); + plan.assert_visible_lacks(&["agents"]); + assert!( + plan.registered_names + .contains(&ToolName::plain("spawn_agent").to_string()) ); assert!( - !default_tools - .iter() - .any(|tool| tool.name() == "image_generation"), - "image_generation should be disabled when the feature is disabled" - ); - - let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (supported_tools, _) = build_specs( - &supported_tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert_contains_tool_names(&supported_tools, &["image_generation"]); - let image_generation_tool = find_tool(&supported_tools, "image_generation"); - assert_eq!( - serde_json::to_value(image_generation_tool).expect("serialize image tool"), - serde_json::json!({ - "type": "image_generation", - "output_format": "png" - }) - ); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &unsupported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - assert!( - !tools.iter().any(|tool| tool.name() == "image_generation"), - "image_generation should be disabled for unsupported models" + !plan + .registered_names + .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) ); } -#[test] -fn web_search_mode_cached_sets_external_web_access_false() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(false), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); -} - -#[test] -fn web_search_mode_live_sets_external_web_access_true() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: None, - user_location: None, - search_context_size: None, - search_content_types: None, - } - ); -} - -#[test] -fn web_search_config_is_forwarded_to_tool_spec() { - let model_info = model_info(); - let features = Features::with_defaults(); - let web_search_config = WebSearchConfig { - filters: Some(codex_protocol::config_types::WebSearchFilters { - allowed_domains: Some(vec!["example.com".to_string()]), - }), - user_location: Some(codex_protocol::config_types::WebSearchUserLocation { - r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate, - country: Some("US".to_string()), - region: Some("California".to_string()), - city: Some("San Francisco".to_string()), - timezone: Some("America/Los_Angeles".to_string()), - }), - search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High), - }; - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, +#[tokio::test] +async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { + let plan = probe(|turn| { + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::MultiAgentV2, + ], + ); + update_config(turn, |config| { + config.multi_agent_v2.non_code_mode_only = true; + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); }) - .with_web_search_config(Some(web_search_config.clone())); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + .await; - let tool = find_tool(&tools, "web_search"); - assert_eq!( - tool.clone(), - ToolSpec::WebSearch { - external_web_access: Some(true), - filters: web_search_config - .filters - .map(ResponsesApiWebSearchFilters::from), - user_location: web_search_config - .user_location - .map(ResponsesApiWebSearchUserLocation::from), - search_context_size: web_search_config.search_context_size, - search_content_types: None, - } - ); + assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] { + assert!( + plan.namespace_function_names("agents") + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in agents namespace" + ); + } } -#[test] -fn web_search_tool_type_text_and_image_sets_search_content_types() { - let mut model_info = model_info(); - model_info.web_search_tool_type = WebSearchToolType::TextAndImage; - let features = Features::with_defaults(); +#[tokio::test] +async fn hosted_tools_follow_provider_auth_model_and_config_gates() { + let api_key_auth = probe(|turn| { + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + api_key_auth.assert_visible_lacks(&["image_generation"]); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); + let image_generation = probe(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + image_generation.assert_visible_contains(&["image_generation"]); - let tool = find_tool(&tools, "web_search"); + let live_web_search = probe(|turn| { + set_web_search_mode(turn, WebSearchMode::Live); + turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage; + }) + .await; assert_eq!( - tool.clone(), - ToolSpec::WebSearch { + live_web_search.visible_spec("web_search"), + &ToolSpec::WebSearch { external_web_access: Some(true), filters: None, user_location: None, @@ -1274,1574 +787,11 @@ fn web_search_tool_type_text_and_image_sets_search_content_types() { search_content_types: Some(vec!["text".to_string(), "image".to_string()]), } ); -} - -#[test] -fn mcp_resource_tools_are_hidden_without_mcp_servers() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert!( - !tools.iter().any(|tool| matches!( - tool.name(), - "list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource" - )), - "MCP resource tools should be omitted when no MCP servers are configured" - ); -} - -#[test] -fn mcp_resource_tools_are_included_when_mcp_servers_are_present() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::new()), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names( - &tools, - &[ - "list_mcp_resources", - "list_mcp_resource_templates", - "read_mcp_resource", - ], - ); -} - -#[test] -#[ignore] -fn test_parallel_support_flags() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names(&tools, &["exec_command", "write_stdin"]); -} - -#[test] -fn test_test_model_info_includes_sync_tool() { - let mut model_info = model_info(); - model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()]; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool")); -} - -#[test] -fn test_build_specs_mcp_tools_converted() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("test_server/", "do_something_cool"), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({ - "type": "object", - "properties": { - "string_argument": { "type": "string" }, - "number_argument": { "type": "number" }, - "object_argument": { - "type": "object", - "properties": { - "string_property": { "type": "string" }, - "number_property": { "type": "number" }, - }, - "required": ["string_property", "number_property"], - "additionalProperties": false, - }, - }, - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); - assert_eq!( - tool, - &ResponsesApiTool { - name: "do_something_cool".to_string(), - parameters: JsonSchema::object( - BTreeMap::from([ - ( - "string_argument".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_argument".to_string(), - JsonSchema::number(/*description*/ None), - ), - ( - "object_argument".to_string(), - JsonSchema::object( - BTreeMap::from([ - ( - "string_property".to_string(), - JsonSchema::string(/*description*/ None), - ), - ( - "number_property".to_string(), - JsonSchema::number(/*description*/ None), - ), - ]), - Some(vec![ - "string_property".to_string(), - "number_property".to_string(), - ]), - Some(false.into()), - ), - ), - ]), - /*required*/ None, - /*additional_properties*/ None - ), - description: "Do something cool".to_string(), - strict: false, - output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))), - defer_loading: None, - } - ); -} - -#[test] -fn namespace_specs_are_hidden_when_namespace_tools_are_disabled() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - - let (tools, registry) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_lacks_tool_name(&tools, "mcp__sample__"); - assert!(registry.has_tool(&ToolName::namespaced("mcp__sample__", "echo"))); -} - -#[test] -fn namespaced_dynamic_specs_are_hidden_when_namespace_tools_are_disabled() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create or update automations.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: false, - }, - DynamicToolSpec { - namespace: None, - name: "plain_dynamic".to_string(), - description: "Plain dynamic tool.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: false, - }, - ]; - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - assert_lacks_tool_name(&tools, "codex_app"); - assert_contains_tool_names(&tools, &["plain_dynamic"]); -} - -#[test] -fn test_build_specs_mcp_namespace_description_falls_back_when_missing() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("test_server/", "do_something_cool"), - mcp_tool( - "do_something_cool", - "Do something cool", - serde_json::json!({"type": "object"}), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let namespace_tool = find_tool(&tools, "test_server/"); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool"); - }; - assert_eq!( - namespace.description, - "Tools in the test_server/ namespace." - ); -} - -#[test] -fn test_build_specs_mcp_tools_sorted_by_name() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let tools_map = HashMap::from([ - ( - ToolName::namespaced("test_server/", "do"), - mcp_tool("do", "a", serde_json::json!({"type": "object"})), - ), - ( - ToolName::namespaced("test_server/", "something"), - mcp_tool("something", "b", serde_json::json!({"type": "object"})), - ), - ( - ToolName::namespaced("test_server/", "cool"), - mcp_tool("cool", "c", serde_json::json!({"type": "object"})), - ), - ]); - - let (tools, _) = build_specs( - &tools_config, - Some(tools_map), - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_eq!( - namespace_function_names(&tools, "test_server/"), - vec![ - "cool".to_string(), - "do".to_string(), - "something".to_string(), - ] - ); -} - -#[test] -fn search_tool_description_lists_each_mcp_source_once() { - let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ - deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - ), - deferred_mcp_tool( - "_list_events", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - ), - deferred_mcp_tool( - "_search_threads", - "mcp__codex_apps__gmail", - CODEX_APPS_MCP_SERVER_NAME, - Some("Gmail"), - Some("Find and summarize email threads."), - ), - deferred_mcp_tool( - "echo", - "mcp__rmcp__", - "rmcp", - /*connector_name*/ None, - Some("Remote memory tools."), - ), - ]), - &[], - ); - - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - let description = description.as_str(); - assert!(description.contains("- Calendar: Plan events and manage your calendar.")); - assert!(description.contains("- Gmail: Find and summarize email threads.")); - assert_eq!( - description - .matches("- Calendar: Plan events and manage your calendar.") - .count(), - 1 - ); - assert!(description.contains("- rmcp: Remote memory tools.")); - assert!(!description.contains("mcp__rmcp__echo")); - - assert!(registry.has_tool(&ToolName::namespaced( - "mcp__codex_apps__calendar", - "_create_event", - ))); - assert!(registry.has_tool(&ToolName::namespaced("mcp__rmcp__", "echo"))); -} - -#[test] -fn search_tool_requires_model_capability() { - let model_info = search_capable_model_info(); - let deferred_mcp_tools = Some(vec![deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - /*description*/ None, - )]); - - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &ModelInfo { - supports_search_tool: false, - ..model_info.clone() - }, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools.clone(), - &[], - ); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - deferred_mcp_tools, - &[], - ); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); -} - -#[test] -fn no_search_tool_when_namespaces_disabled() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![deferred_mcp_tool( - "_create_event", - "mcp__codex_apps__calendar", - CODEX_APPS_MCP_SERVER_NAME, - Some("Calendar"), - Some("Plan events and manage your calendar."), - )]), - &[], - ); - - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); -} - -#[test] -fn search_tool_registers_for_deferred_dynamic_tools() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create, update, view, or delete recurring automations.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "mode": { "type": "string" }, - }, - }), - defer_loading: true, - }, - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_list".to_string(), - description: "List recurring automations.".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - }), - defer_loading: true, - }, - ]; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::ToolSearch { description, .. } = search_tool else { - panic!("expected tool_search tool"); - }; - assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread.")); - assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); - assert_lacks_tool_name(&tools, "codex_app"); - assert!(registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_list"))); -} - -#[test] -fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_disabled() { - let model_info = search_capable_model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.namespace_tools = false; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "automation_update".to_string(), - description: "Create or update automations.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - DynamicToolSpec { - namespace: None, - name: "plain_dynamic".to_string(), - description: "Plain dynamic tool.".to_string(), - input_schema: json!({"type": "object", "properties": {}}), - defer_loading: true, - }, - ]; - - let (tools, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &dynamic_tools, - ); - - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert_lacks_tool_name(&tools, "codex_app"); - assert_lacks_tool_name(&tools, "plain_dynamic"); - assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_tool(&ToolName::plain("plain_dynamic"))); -} - -#[test] -fn request_plugin_install_is_not_registered_without_feature_flag() { - let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); - features.disable(Feature::ToolSuggest); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(vec![discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - )]), - /*extension_tool_executors*/ &[], - &[], - ); - - assert!( - !tools - .iter() - .any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME) - ); -} - -#[test] -fn request_plugin_install_can_be_registered_without_search_tool() { - let model_info = ModelInfo { - supports_search_tool: false, - ..search_capable_model_info() - }; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); - features.enable(Feature::ToolSuggest); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(vec![discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - )]), - /*extension_tool_executors*/ &[], - &[], - ); - - assert_contains_tool_names(&tools, &[REQUEST_PLUGIN_INSTALL_TOOL_NAME]); - let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); - assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - - let ToolSpec::Function(ResponsesApiTool { description, .. }) = request_plugin_install else { - panic!("expected function tool"); - }; - assert!(description.contains( - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed." - )); - assert!(description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); -} - -#[test] -fn request_plugin_install_description_lists_discoverable_tools() { - let model_info = search_capable_model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); - features.enable(Feature::ToolSuggest); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let discoverable_tools = vec![ - discoverable_connector( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - "Plan events and schedules.", - ), - discoverable_connector( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - "Find and summarize email threads.", - ), - DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "sample@test".to_string(), - name: "Sample Plugin".to_string(), - description: None, - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_sample".to_string()], - })), - ]; - - let (tools, registry) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - Some(discoverable_tools), - /*extension_tool_executors*/ &[], - &[], - ); - assert!(registry.has_tool(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); - - let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { - description, - parameters, - .. - }) = request_plugin_install - else { - panic!("expected function tool"); - }; - assert!(description.contains( - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed." - )); - assert!(description.contains("Google Calendar")); - assert!(description.contains("Gmail")); - assert!(description.contains("Sample Plugin")); - assert!(description.contains("Plan events and schedules.")); - assert!(description.contains("Find and summarize email threads.")); - assert!(description.contains("id: `sample@test`, type: plugin, action: install")); - assert!(description.contains("`action_type`: `install`")); - assert!( - description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") - ); - assert!( - description.contains( - "The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list." - ) - ); - assert!(description.contains( - "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." - )); - assert!(description.contains( - "The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list." - )); - assert!(description.contains( - "Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful." - )); - assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); - assert!(description.contains( - "If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool." - )); - assert!(!description.contains("targeted lookup")); - assert!(!description.contains("broad or speculative searches")); - assert!(description.contains("Only proceed when one listed plugin or connector exactly fits.")); - assert!(description.contains( - "If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not." - )); - assert!(!description.contains("{{discoverable_tools}}")); - assert!(!description.contains("tool_search fails to find a good match")); - let (_, required) = expect_object_schema(parameters); - assert_eq!( - required, - Some(&vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]) - ); -} - -#[test] -fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - mcp_tool( - "echo", - "Echo text", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"], - "additionalProperties": false - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains( - r#"### `mcp__sample__echo` -Echo text - -exec tool declaration: -```ts -declare const tools: { mcp__sample__echo(args: { message: string; }): Promise; }; -```"# - )); -} - -#[test] -fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "fn"), - mcp_tool( - "fn", - "Sample fn", - serde_json::json!({ - "type": "object", - "properties": { - "open": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "ref_id": {"type": "string"}, - "lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]} - }, - "required": ["ref_id"], - "additionalProperties": false - } - }, - {"type": "null"} - ] - }, - "tagged_list": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "kind": {"type": "const", "const": "tagged"}, - "variant": {"type": "enum", "enum": ["alpha", "beta"]}, - "scope": {"type": "enum", "enum": ["one", "two"]} - }, - "required": ["kind", "variant", "scope"] - } - }, - {"type": "null"} - ] - }, - "response_length": {"type": "enum", "enum": ["short", "medium", "long"]} - }, - "additionalProperties": false - }), - ), - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "fn"); - - assert!(description.contains( - r#"exec tool declaration: -```ts -declare const tools: { mcp__sample__fn(args: { open?: Array<{ lineno?: number | null; ref_id: string; }> | null; response_length?: "short" | "medium" | "long"; tagged_list?: Array<{ kind: "tagged"; scope: "one" | "two"; variant: "alpha" | "beta"; }> | null; }): Promise; }; -```"# - )); -} - -#[test] -fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = - find_tool(&tools, VIEW_IMAGE_TOOL_NAME) - else { - panic!("expected function tool"); - }; - - assert_eq!( - description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" - ); -} - -#[test] -fn code_mode_only_exec_description_includes_full_nested_tool_details() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(!description.contains("Enabled nested tools:")); - assert!(!description.contains("Nested tool reference:")); - assert!(description.starts_with("Run JavaScript code to orchestrate/compose tool calls")); - assert!(!description.contains("do not attempt to use any other tools directly")); - assert!(description.contains("### `update_plan`")); - assert!(description.contains("### `view_image`")); -} - -#[test] -fn code_mode_only_exec_description_includes_extension_tool_details() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let extension_tool_executors = vec![extension_tool_executor( - "extension_echo", - "Echoes arguments through an extension tool.", - )]; - let (tools, _) = build_specs_with_inputs_for_test( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - /*discoverable_tools*/ None, - &extension_tool_executors, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains("### `extension_echo`")); - assert!(description.contains("Echoes arguments through an extension tool.")); -} - -#[test] -fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(!description.starts_with( - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" - )); - assert!(!description.contains("### `update_plan`")); - assert!(!description.contains("### `view_image`")); -} - -fn model_info() -> ModelInfo { - serde_json::from_value(json!({ - "slug": "gpt-5-codex", - "display_name": "GPT-5 Codex", - "description": null, - "supported_reasoning_levels": [], - "shell_type": "shell_command", - "visibility": "list", - "supported_in_api": true, - "priority": 1, - "availability_nux": null, - "upgrade": null, - "base_instructions": "base", - "model_messages": null, - "supports_reasoning_summaries": false, - "default_reasoning_summary": "auto", - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": "freeform", - "truncation_policy": { - "mode": "bytes", - "limit": 10000 - }, - "supports_parallel_tool_calls": false, - "supports_image_detail_original": false, - "context_window": null, - "auto_compact_token_limit": null, - "effective_context_window_percent": 95, - "experimental_supported_tools": [], - "input_modalities": ["text", "image"], - "supports_search_tool": false - })) - .expect("deserialize test model") -} - -fn search_capable_model_info() -> ModelInfo { - ModelInfo { - supports_search_tool: true, - ..model_info() - } -} - -fn build_specs( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - dynamic_tools: &[DynamicToolSpec], -) -> (Vec, ToolRegistry) { - build_specs_with_inputs_for_test( - config, - mcp_tools, - deferred_mcp_tools, - /*discoverable_tools*/ None, - /*extension_tool_executors*/ &[], - dynamic_tools, - ) -} - -fn build_specs_with_inputs_for_test( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc>], - dynamic_tools: &[DynamicToolSpec], -) -> (Vec, ToolRegistry) { - let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| { - mcp_tools - .iter() - .map(|(name, tool)| tool_info_from_parts(name, tool.clone())) - .collect::>() - }); - let params = ToolRegistryBuildParams { - mcp_tools: mcp_tool_inputs.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors, - dynamic_tools, - default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION, - wait_agent_timeouts: wait_agent_timeout_options(), - }; - let mut executors = collect_tool_executors(config, params); - append_tool_search_executor(config, &mut executors); - prepend_code_mode_executors(config, &mut executors); - build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) -} - -fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } -} - -fn tool_info_from_parts(name: &ToolName, tool: rmcp::model::Tool) -> ToolInfo { - ToolInfo { - server_name: server_name_from_tool_name(name), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: name.name.clone(), - callable_namespace: name.namespace.clone().unwrap_or_default(), - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - -fn server_name_from_tool_name(name: &ToolName) -> String { - name.namespace - .as_deref() - .and_then(|namespace| { - namespace - .strip_prefix("mcp__") - .and_then(|suffix| suffix.strip_suffix("__")) - }) - .unwrap_or_else(|| name.namespace.as_deref().unwrap_or("test_server")) - .to_string() -} - -#[test] -fn code_mode_augments_mcp_tool_descriptions_with_structured_output_sample() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::CodeMode); - features.enable(Feature::CodeModeOnly); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let mut tool = mcp_tool( - "echo", - "Echo text", - serde_json::json!({ - "type": "object", - "properties": { - "message": {"type": "string"} - }, - "required": ["message"], - "additionalProperties": false - }), - ); - tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object( - serde_json::json!({ - "type": "object", - "properties": { - "echo": {"type": "string"}, - "env": { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } - }, - "required": ["echo", "env"], - "additionalProperties": false - }), - ))); - - let (tools, _) = build_specs( - &tools_config, - Some(HashMap::from([( - ToolName::namespaced("mcp__sample__", "echo"), - tool, - )])), - /*deferred_mcp_tools*/ None, - &[], - ); - - let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { - panic!("expected freeform tool"); - }; - - assert!(description.contains( - r#"### `mcp__sample__echo` -Echo text - -exec tool declaration: -```ts -declare const tools: { mcp__sample__echo(args: { message: string; }): Promise>; }; -```"# - )); -} - -fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { - let slug = name.replace(' ', "-").to_lowercase(); - DiscoverableTool::Connector(Box::new(AppInfo { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })) -} - -fn deferred_mcp_tool( - tool_name: &str, - tool_namespace: &str, - server_name: &str, - connector_name: Option<&str>, - description: Option<&str>, -) -> ToolInfo { - ToolInfo { - server_name: server_name.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: tool_name.to_string(), - callable_namespace: tool_namespace.to_string(), - namespace_description: description.map(str::to_string), - tool: mcp_tool( - tool_name, - description.unwrap_or("Deferred MCP tool"), - json!({}), - ), - connector_id: None, - connector_name: connector_name.map(str::to_string), - plugin_display_names: Vec::new(), - } -} - -fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { - use std::collections::HashSet; - - let mut names = HashSet::new(); - let mut duplicates = Vec::new(); - for name in tools.iter().map(ToolSpec::name) { - if !names.insert(name) { - duplicates.push(name); - } - } - assert!( - duplicates.is_empty(), - "duplicate tool entries detected: {duplicates:?}" - ); - for expected in expected_subset { - assert!( - names.contains(expected), - "expected tool {expected} to be present; had: {names:?}" - ); - } -} - -fn assert_lacks_tool_name(tools: &[ToolSpec], expected_absent: &str) { - let names = tools.iter().map(ToolSpec::name).collect::>(); - assert!( - !names.contains(&expected_absent), - "expected tool {expected_absent} to be absent; had: {names:?}" - ); -} - -fn request_user_input_tool_spec(available_modes: &[ModeKind]) -> ToolSpec { - create_request_user_input_tool(request_user_input_tool_description(available_modes)) -} - -fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions { - SpawnAgentToolOptions { - available_models: config.available_models.clone(), - agent_type_description: agent_type_description(config, DEFAULT_AGENT_TYPE_DESCRIPTION), - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - } -} - -fn wait_agent_timeout_options() -> WaitAgentTimeoutOptions { - WaitAgentTimeoutOptions { - default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS, - min_timeout_ms: MIN_WAIT_TIMEOUT_MS, - max_timeout_ms: MAX_WAIT_TIMEOUT_MS, - } -} - -fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec { - tools - .iter() - .find(|tool| tool.name() == expected_name) - .unwrap_or_else(|| panic!("expected tool {expected_name}")) -} - -fn assert_namespace_contains_function( - tools: &[ToolSpec], - expected_namespace: &str, - expected_name: &str, -) { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - assert!( - namespace.tools.iter().any(|tool| { - matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name) - }), - "expected tool {expected_name} in namespace {expected_namespace}" - ); -} - -fn assert_process_tool_environment_id( - tools: &[ToolSpec], - expected_name: &str, - expected_present: bool, -) { - let tool = find_tool(tools, expected_name); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { - panic!("expected function tool {expected_name}"); - }; - let (properties, _) = expect_object_schema(parameters); - assert_eq!( - properties.contains_key("environment_id"), - expected_present, - "{expected_name} environment_id parameter presence" - ); -} - -fn assert_apply_patch_environment_id(tools: &[ToolSpec], expected_present: bool) { - let tool = find_tool(tools, "apply_patch"); - let ToolSpec::Freeform(FreeformTool { format, .. }) = tool else { - panic!("expected freeform apply_patch tool"); - }; - assert_eq!( - format.definition.contains("environment_id?"), - expected_present, - "apply_patch environment_id grammar presence" - ); -} - -fn find_namespace_function_tool<'a>( - tools: &'a [ToolSpec], - expected_namespace: &str, - expected_name: &str, -) -> &'a ResponsesApiTool { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool), - _ => None, - }) - .unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace")) -} - -fn namespace_function_names(tools: &[ToolSpec], expected_namespace: &str) -> Vec { - let namespace_tool = find_tool(tools, expected_namespace); - let ToolSpec::Namespace(namespace) = namespace_tool else { - panic!("expected namespace tool {expected_namespace}"); - }; - namespace - .tools - .iter() - .map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(), - }) - .collect() -} - -fn expect_object_schema( - schema: &JsonSchema, -) -> (&BTreeMap, Option<&Vec>) { - assert_eq!( - schema.schema_type, - Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) - ); - let properties = schema - .properties - .as_ref() - .expect("expected object properties"); - (properties, schema.required.as_ref()) -} - -fn expect_string_description(schema: &JsonSchema) -> &str { - assert_eq!( - schema.schema_type, - Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::String)) - ); - schema.description.as_deref().expect("expected description") -} - -fn strip_descriptions_schema(schema: &mut JsonSchema) { - if let Some(variants) = &mut schema.any_of { - for variant in variants { - strip_descriptions_schema(variant); - } - } - if let Some(items) = &mut schema.items { - strip_descriptions_schema(items); - } - if let Some(properties) = &mut schema.properties { - for value in properties.values_mut() { - strip_descriptions_schema(value); - } - } - if let Some(AdditionalProperties::Schema(schema)) = &mut schema.additional_properties { - strip_descriptions_schema(schema); - } - schema.description = None; -} - -fn strip_descriptions_tool(spec: &mut ToolSpec) { - match spec { - ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters), - ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { - strip_descriptions_schema(parameters); - } - ToolSpec::Namespace(namespace) => { - for tool in &mut namespace.tools { - match tool { - ResponsesApiNamespaceTool::Function(ResponsesApiTool { - parameters, .. - }) => { - strip_descriptions_schema(parameters); - } - } - } - } - ToolSpec::Freeform(FreeformTool { .. }) - | ToolSpec::ImageGeneration { .. } - | ToolSpec::WebSearch { .. } => {} - } + + let unsupported_provider = probe(|turn| { + set_web_search_mode(turn, WebSearchMode::Live); + use_bedrock_provider(turn); + }) + .await; + unsupported_provider.assert_visible_lacks(&["web_search"]); } diff --git a/codex-rs/core/src/tools/tool_family/mod.rs b/codex-rs/core/src/tools/tool_family/mod.rs new file mode 100644 index 0000000000..d1e1cbd5a9 --- /dev/null +++ b/codex-rs/core/src/tools/tool_family/mod.rs @@ -0,0 +1 @@ +pub(crate) mod shell; diff --git a/codex-rs/core/src/tools/tool_family/shell.rs b/codex-rs/core/src/tools/tool_family/shell.rs new file mode 100644 index 0000000000..10777562b8 --- /dev/null +++ b/codex-rs/core/src/tools/tool_family/shell.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use codex_protocol::openai_models::ConfigShellToolType; +use codex_tools::ShellCommandBackendConfig; +use codex_tools::ToolEnvironmentMode; + +use crate::tools::handlers::ExecCommandHandler; +use crate::tools::handlers::ExecCommandHandlerOptions; +use crate::tools::handlers::ShellCommandHandler; +use crate::tools::handlers::ShellCommandHandlerOptions; +use crate::tools::handlers::WriteStdinHandler; +use crate::tools::registry::CoreToolRuntime; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ShellToolsOptions { + pub(crate) shell_type: ConfigShellToolType, + pub(crate) shell_command_backend: ShellCommandBackendConfig, + pub(crate) environment_mode: ToolEnvironmentMode, + pub(crate) allow_login_shell: bool, + pub(crate) exec_permission_approvals_enabled: bool, +} + +pub(crate) fn build_shell_tools(options: ShellToolsOptions) -> Vec> { + let mut runtimes = Vec::new(); + if !options.environment_mode.has_environment() { + return runtimes; + } + + let include_environment_id = matches!(options.environment_mode, ToolEnvironmentMode::Multiple); + match options.shell_type { + ConfigShellToolType::UnifiedExec => { + add_runtime( + &mut runtimes, + ExecCommandHandler::new(ExecCommandHandlerOptions { + allow_login_shell: options.allow_login_shell, + exec_permission_approvals_enabled: options.exec_permission_approvals_enabled, + include_environment_id, + }), + ); + add_runtime(&mut runtimes, WriteStdinHandler); + + // Keep the legacy shell tool registered as a hidden runtime while + // unified exec is model-visible. + add_runtime( + &mut runtimes, + ShellCommandHandler::from(options.shell_command_backend), + ); + } + ConfigShellToolType::Disabled => {} + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand => { + add_runtime( + &mut runtimes, + ShellCommandHandler::new(ShellCommandHandlerOptions { + backend_config: options.shell_command_backend, + allow_login_shell: options.allow_login_shell, + exec_permission_approvals_enabled: options.exec_permission_approvals_enabled, + }), + ); + } + } + + runtimes +} + +fn add_runtime(runtimes: &mut Vec>, runtime: T) +where + T: CoreToolRuntime + 'static, +{ + runtimes.push(Arc::new(runtime)); +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index e8e4f87e93..8c0cc89680 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1014,10 +1014,8 @@ impl UnifiedExecProcessManager { local_policy_env, }; let mut orchestrator = ToolOrchestrator::new(); - let mut runtime = UnifiedExecRuntime::new( - self, - context.turn.tools_config.unified_exec_shell_mode.clone(), - ); + let mut runtime = + UnifiedExecRuntime::new(self, context.turn.unified_exec_shell_mode.clone()); let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index 68f4080957..92cccaf30a 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -1,7 +1,7 @@ # codex-tools -`codex-tools` is the shared support crate for building, adapting, planning, and -executing model-visible tool sets outside `codex-core`. +`codex-tools` is the shared support crate for building, adapting, and executing +model-visible tools outside `codex-core`. Today this crate owns the host-facing tool models and helpers that no longer need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: @@ -9,8 +9,8 @@ need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`: - aggregate host models such as `ToolSpec`, `ConfiguredToolSpec`, `LoadableToolSpec`, `ResponsesApiNamespace`, and `ResponsesApiNamespaceTool` -- host config and discovery models used while assembling tool sets, including - `ToolsConfig`, discoverable-tool models, and request-plugin-install helpers +- host discovery models used while assembling tool sets, including + discoverable-tool models and request-plugin-install helpers - host adapters such as schema sanitization, MCP/dynamic conversion, code-mode augmentation, and image-detail normalization - shared executable-tool contracts such as `ToolExecutor`, `ToolCall`, and diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2a87ba35d7..4d972af2cf 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -61,11 +61,11 @@ pub use tool_call::ToolCall; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; -pub use tool_config::ToolsConfig; -pub use tool_config::ToolsConfigParams; pub use tool_config::UnifiedExecShellMode; pub use tool_config::ZshForkConfig; pub use tool_config::request_user_input_available_modes; +pub use tool_config::shell_command_backend_for_features; +pub use tool_config::shell_type_for_model_and_features; pub use tool_definition::ToolDefinition; pub use tool_discovery::DiscoverablePluginInfo; pub use tool_discovery::DiscoverableTool; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index b6f4ae8dbd..3eaa73f189 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -1,20 +1,9 @@ -use crate::can_request_original_image_detail; use codex_features::Feature; use codex_features::Features; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; -use codex_protocol::config_types::WebSearchConfig; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::WebSearchToolType; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::PathBuf; @@ -44,6 +33,44 @@ pub fn request_user_input_available_modes(features: &Features) -> Vec .collect() } +pub fn shell_command_backend_for_features(features: &Features) -> ShellCommandBackendConfig { + if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { + ShellCommandBackendConfig::ZshFork + } else { + ShellCommandBackendConfig::Classic + } +} + +pub fn shell_type_for_model_and_features( + model_info: &ModelInfo, + features: &Features, +) -> ConfigShellToolType { + let unified_exec_enabled = features.enabled(Feature::UnifiedExec); + let model_shell_type = match model_info.shell_type { + ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { + ConfigShellToolType::ShellCommand + } + ConfigShellToolType::Default | ConfigShellToolType::Local => { + ConfigShellToolType::ShellCommand + } + other => other, + }; + + if !features.enabled(Feature::ShellTool) { + ConfigShellToolType::Disabled + } else if features.enabled(Feature::ShellZshFork) { + ConfigShellToolType::ShellCommand + } else if unified_exec_enabled { + if codex_utils_pty::conpty_supported() { + ConfigShellToolType::UnifiedExec + } else { + ConfigShellToolType::ShellCommand + } + } else { + model_shell_type + } +} + #[derive(Debug, Clone, Eq, PartialEq)] pub enum UnifiedExecShellMode { Direct, @@ -93,57 +120,6 @@ impl UnifiedExecShellMode { } } -#[derive(Debug, Clone)] -pub struct ToolsConfig { - pub available_models: Vec, - pub shell_type: ConfigShellToolType, - pub shell_command_backend: ShellCommandBackendConfig, - pub unified_exec_shell_mode: UnifiedExecShellMode, - pub environment_mode: ToolEnvironmentMode, - pub allow_login_shell: bool, - pub apply_patch_tool_type: Option, - pub web_search_mode: Option, - pub web_search_config: Option, - pub web_search_tool_type: WebSearchToolType, - pub image_gen_tool: bool, - pub search_tool: bool, - pub namespace_tools: bool, - pub tool_suggest: bool, - pub exec_permission_approvals_enabled: bool, - pub request_permissions_tool_enabled: bool, - pub code_mode_enabled: bool, - pub code_mode_only_enabled: bool, - pub can_request_original_image_detail: bool, - pub collab_tools: bool, - pub goal_tools: bool, - pub multi_agent_v2: bool, - pub multi_agent_v2_non_code_mode_only: bool, - pub hide_spawn_agent_metadata: bool, - pub spawn_agent_usage_hint: bool, - pub spawn_agent_usage_hint_text: Option, - pub multi_agent_v2_tool_namespace: Option, - pub max_concurrent_threads_per_session: Option, - pub wait_agent_min_timeout_ms: Option, - pub wait_agent_max_timeout_ms: Option, - pub wait_agent_default_timeout_ms: Option, - pub request_user_input_available_modes: Vec, - pub experimental_supported_tools: Vec, - pub agent_jobs_tools: bool, - pub agent_jobs_worker_tools: bool, - pub agent_type_description: String, -} - -pub struct ToolsConfigParams<'a> { - pub model_info: &'a ModelInfo, - pub available_models: &'a [ModelPreset], - pub features: &'a Features, - pub image_generation_tool_auth_allowed: bool, - pub web_search_mode: Option, - pub session_source: SessionSource, - pub permission_profile: &'a PermissionProfile, - pub windows_sandbox_level: WindowsSandboxLevel, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolEnvironmentMode { None, @@ -165,262 +141,6 @@ impl ToolEnvironmentMode { } } -impl ToolsConfig { - pub fn new(params: &ToolsConfigParams<'_>) -> Self { - let ToolsConfigParams { - model_info, - available_models, - features, - image_generation_tool_auth_allowed, - web_search_mode, - session_source, - .. - } = params; - let include_code_mode = features.enabled(Feature::CodeMode); - let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); - let include_goal_tools = features.enabled(Feature::Goals); - let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); - let include_collab_tools = include_multi_agent_v2 || features.enabled(Feature::Collab); - let include_agent_jobs = features.enabled(Feature::SpawnCsv); - let include_search_tool = model_info.supports_search_tool; - let include_tool_suggest = features.enabled(Feature::ToolSuggest) - && features.enabled(Feature::Apps) - && features.enabled(Feature::Plugins); - let include_original_image_detail = can_request_original_image_detail(model_info); - // API-key auth bypasses Codex backend entitlement/tool normalization, so - // callers must confirm ChatGPT auth before exposing the built-in tool. - let include_image_gen_tool = *image_generation_tool_auth_allowed - && features.enabled(Feature::ImageGeneration) - && supports_image_generation(model_info); - let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); - let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); - let shell_command_backend = - if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { - ShellCommandBackendConfig::ZshFork - } else { - ShellCommandBackendConfig::Classic - }; - let unified_exec_enabled = features.enabled(Feature::UnifiedExec); - let model_shell_type = match model_info.shell_type { - ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { - ConfigShellToolType::ShellCommand - } - ConfigShellToolType::Default | ConfigShellToolType::Local => { - ConfigShellToolType::ShellCommand - } - other => other, - }; - let shell_type = if !features.enabled(Feature::ShellTool) { - ConfigShellToolType::Disabled - } else if features.enabled(Feature::ShellZshFork) { - ConfigShellToolType::ShellCommand - } else if unified_exec_enabled { - if codex_utils_pty::conpty_supported() { - ConfigShellToolType::UnifiedExec - } else { - ConfigShellToolType::ShellCommand - } - } else { - model_shell_type - }; - - let apply_patch_tool_type = model_info.apply_patch_tool_type.clone(); - - let agent_jobs_worker_tools = include_agent_jobs - && matches!( - session_source, - SessionSource::SubAgent(SubAgentSource::Other(label)) - if label.starts_with("agent_job:") - ); - - Self { - available_models: available_models.to_vec(), - shell_type, - shell_command_backend, - unified_exec_shell_mode: UnifiedExecShellMode::Direct, - environment_mode: ToolEnvironmentMode::Single, - allow_login_shell: true, - apply_patch_tool_type, - web_search_mode: *web_search_mode, - web_search_config: None, - web_search_tool_type: model_info.web_search_tool_type, - image_gen_tool: include_image_gen_tool, - search_tool: include_search_tool, - namespace_tools: true, - tool_suggest: include_tool_suggest, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - code_mode_enabled: include_code_mode, - code_mode_only_enabled: include_code_mode_only, - can_request_original_image_detail: include_original_image_detail, - collab_tools: include_collab_tools, - goal_tools: include_goal_tools, - multi_agent_v2: include_multi_agent_v2, - multi_agent_v2_non_code_mode_only: false, - hide_spawn_agent_metadata: false, - spawn_agent_usage_hint: true, - spawn_agent_usage_hint_text: None, - multi_agent_v2_tool_namespace: None, - max_concurrent_threads_per_session: None, - wait_agent_min_timeout_ms: None, - wait_agent_max_timeout_ms: None, - wait_agent_default_timeout_ms: None, - request_user_input_available_modes: request_user_input_available_modes(features), - experimental_supported_tools: model_info.experimental_supported_tools.clone(), - agent_jobs_tools: include_agent_jobs, - agent_jobs_worker_tools, - agent_type_description: String::new(), - } - } - - pub fn with_agent_type_description(mut self, agent_type_description: String) -> Self { - self.agent_type_description = agent_type_description; - self - } - - pub fn with_namespace_tools_capability(mut self, namespace_tools: bool) -> Self { - if !namespace_tools { - self.namespace_tools = false; - } - self - } - - pub fn with_image_generation_capability(mut self, image_generation: bool) -> Self { - if !image_generation { - self.image_gen_tool = false; - } - self - } - - pub fn with_web_search_capability(mut self, web_search: bool) -> Self { - if !web_search { - self.web_search_mode = None; - } - self - } - - pub fn with_spawn_agent_usage_hint(mut self, spawn_agent_usage_hint: bool) -> Self { - self.spawn_agent_usage_hint = spawn_agent_usage_hint; - self - } - - pub fn with_spawn_agent_usage_hint_text( - mut self, - spawn_agent_usage_hint_text: Option, - ) -> Self { - self.spawn_agent_usage_hint_text = spawn_agent_usage_hint_text; - self - } - - pub fn with_hide_spawn_agent_metadata(mut self, hide_spawn_agent_metadata: bool) -> Self { - self.hide_spawn_agent_metadata = hide_spawn_agent_metadata; - self - } - - pub fn with_multi_agent_v2_tool_namespace( - mut self, - multi_agent_v2_tool_namespace: Option, - ) -> Self { - self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace; - self - } - - pub fn with_multi_agent_v2_non_code_mode_only( - mut self, - multi_agent_v2_non_code_mode_only: bool, - ) -> Self { - self.multi_agent_v2_non_code_mode_only = - self.multi_agent_v2 && multi_agent_v2_non_code_mode_only; - self - } - - pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { - self.goal_tools = self.goal_tools && allowed; - self - } - - pub fn with_max_concurrent_threads_per_session( - mut self, - max_concurrent_threads_per_session: Option, - ) -> Self { - self.max_concurrent_threads_per_session = max_concurrent_threads_per_session; - self - } - - pub fn with_wait_agent_min_timeout_ms( - mut self, - wait_agent_min_timeout_ms: Option, - ) -> Self { - self.wait_agent_min_timeout_ms = wait_agent_min_timeout_ms; - self - } - - pub fn with_wait_agent_max_timeout_ms( - mut self, - wait_agent_max_timeout_ms: Option, - ) -> Self { - self.wait_agent_max_timeout_ms = wait_agent_max_timeout_ms; - self - } - - pub fn with_wait_agent_default_timeout_ms( - mut self, - wait_agent_default_timeout_ms: Option, - ) -> Self { - self.wait_agent_default_timeout_ms = wait_agent_default_timeout_ms; - self - } - - pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { - self.allow_login_shell = allow_login_shell; - self - } - - pub fn with_environment_mode(mut self, environment_mode: ToolEnvironmentMode) -> Self { - self.environment_mode = environment_mode; - self - } - - pub fn with_unified_exec_shell_mode( - mut self, - unified_exec_shell_mode: UnifiedExecShellMode, - ) -> Self { - self.unified_exec_shell_mode = unified_exec_shell_mode; - self - } - - pub fn with_unified_exec_shell_mode_for_session( - mut self, - user_shell_type: ToolUserShellType, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, - ) -> Self { - self.unified_exec_shell_mode = UnifiedExecShellMode::for_session( - self.shell_command_backend, - user_shell_type, - shell_zsh_path, - main_execve_wrapper_exe, - ); - self - } - - pub fn with_web_search_config(mut self, web_search_config: Option) -> Self { - self.web_search_config = web_search_config; - self - } - - pub fn for_code_mode_nested_tools(&self) -> Self { - let mut nested = self.clone(); - nested.code_mode_enabled = false; - nested.code_mode_only_enabled = false; - nested - } -} - -fn supports_image_generation(model_info: &ModelInfo) -> bool { - model_info.input_modalities.contains(&InputModality::Image) -} - #[cfg(test)] #[path = "tool_config_tests.rs"] mod tests; diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 2fa49b03bf..8ce68acbba 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -1,294 +1,153 @@ -use super::*; use codex_features::Feature; use codex_features::Features; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; +use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::TruncationPolicyConfig; use pretty_assertions::assert_eq; -use serde_json::json; -use std::path::PathBuf; -fn model_info() -> ModelInfo { - serde_json::from_value(json!({ - "slug": "test-model", - "display_name": "Test Model", - "description": null, - "supported_reasoning_levels": [], - "shell_type": "unified_exec", - "visibility": "list", - "supported_in_api": true, - "priority": 1, - "availability_nux": null, - "upgrade": null, - "base_instructions": "base", - "model_messages": null, - "supports_reasoning_summaries": false, - "default_reasoning_summary": "auto", - "support_verbosity": false, - "default_verbosity": null, - "apply_patch_tool_type": null, - "truncation_policy": { - "mode": "bytes", - "limit": 10000 - }, - "supports_parallel_tool_calls": false, - "supports_image_detail_original": false, - "context_window": null, - "auto_compact_token_limit": null, - "effective_context_window_percent": 95, - "experimental_supported_tools": [], - "input_modalities": ["text", "image"], - "supports_search_tool": false - })) - .expect("deserialize test model") +use super::*; + +fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { + ModelInfo { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + description: None, + default_reasoning_level: None, + supported_reasoning_levels: Vec::new(), + shell_type, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 0, + additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), + availability_nux: None, + upgrade: None, + base_instructions: String::new(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: Default::default(), + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + web_search_tool_type: Default::default(), + truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 1024), + supports_parallel_tool_calls: true, + supports_image_detail_original: false, + context_window: None, + max_context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: codex_protocol::openai_models::default_input_modalities(), + used_fallback_model_metadata: false, + supports_search_tool: false, + } } -#[test] -fn model_provided_unified_exec_requires_feature_flag() { - let model_info = model_info(); +fn shell_features() -> Features { let mut features = Features::with_defaults(); + features.enable(Feature::ShellTool); + features.disable(Feature::ShellZshFork); features.disable(Feature::UnifiedExec); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); + features } #[test] -fn unified_exec_can_be_enabled_for_restricted_token_workspace_write() { - let model_info = model_info(); - let mut features = Features::with_defaults(); +fn shell_type_is_derived_from_model_and_feature_gates() { + let model = model_with_shell_type(ConfigShellToolType::UnifiedExec); + let mut features = shell_features(); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::ShellCommand + ); + features.enable(Feature::UnifiedExec); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::workspace_write(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - }); - - let expected_shell_type = if codex_utils_pty::conpty_supported() { + let expected_unified_exec = if codex_utils_pty::conpty_supported() { ConfigShellToolType::UnifiedExec } else { ConfigShellToolType::ShellCommand }; - assert_eq!(tools_config.shell_type, expected_shell_type); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + expected_unified_exec + ); + + features.enable(Feature::ShellZshFork); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::ShellCommand + ); + + features.disable(Feature::ShellTool); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::Disabled + ); } #[test] -fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - features.enable(Feature::ShellZshFork); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Live), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); +fn shell_command_backend_requires_both_shell_tool_and_zsh_fork() { + let mut features = shell_features(); assert_eq!( - tools_config.shell_command_backend, + shell_command_backend_for_features(&features), + ShellCommandBackendConfig::Classic + ); + + features.enable(Feature::ShellZshFork); + assert_eq!( + shell_command_backend_for_features(&features), ShellCommandBackendConfig::ZshFork ); + + features.disable(Feature::ShellTool); assert_eq!( - tools_config.unified_exec_shell_mode, + shell_command_backend_for_features(&features), + ShellCommandBackendConfig::Classic + ); +} + +#[test] +fn request_user_input_modes_follow_default_mode_feature() { + let mut features = Features::with_defaults(); + features.disable(Feature::DefaultModeRequestUserInput); + assert_eq!( + request_user_input_available_modes(&features), + vec![ModeKind::Plan] + ); + + features.enable(Feature::DefaultModeRequestUserInput); + assert_eq!( + request_user_input_available_modes(&features), + vec![ModeKind::Default, ModeKind::Plan] + ); +} + +#[test] +fn unified_exec_shell_mode_uses_zsh_fork_only_when_all_inputs_match() { + let exe = std::env::current_exe().expect("current exe path"); + let shell = exe.clone(); + + let mode = UnifiedExecShellMode::for_session( + ShellCommandBackendConfig::ZshFork, + ToolUserShellType::Zsh, + Some(&shell), + Some(&exe), + ); + if cfg!(unix) { + assert!(matches!(mode, UnifiedExecShellMode::ZshFork(_))); + } else { + assert_eq!(mode, UnifiedExecShellMode::Direct); + } + + assert_eq!( + UnifiedExecShellMode::for_session( + ShellCommandBackendConfig::Classic, + ToolUserShellType::Zsh, + Some(&shell), + Some(&exe), + ), UnifiedExecShellMode::Direct ); - assert_eq!( - tools_config - .with_unified_exec_shell_mode_for_session( - ToolUserShellType::Zsh, - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })), - Some(&PathBuf::from(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })), - ) - .unified_exec_shell_mode, - if cfg!(unix) { - UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(), - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path( - "/opt/codex/codex-execve-wrapper", - ) - .unwrap(), - }) - } else { - UnifiedExecShellMode::Direct - } - ); -} - -#[test] -fn fallback_apply_patch_models_do_not_use_freeform_tool_by_default() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!(tools_config.apply_patch_tool_type, None); -} - -#[test] -fn subagents_keep_request_user_input_config_and_agent_jobs_workers_opt_in_by_label() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::DefaultModeRequestUserInput); - features.enable(Feature::SpawnCsv); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::SubAgent(SubAgentSource::Other( - "agent_job:test".to_string(), - )), - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - assert_eq!( - tools_config.request_user_input_available_modes, - request_user_input_available_modes(&features) - ); - assert!(tools_config.agent_jobs_tools); - assert!(tools_config.agent_jobs_worker_tools); -} - -#[test] -fn image_generation_requires_feature_and_supported_model() { - let supported_model_info = model_info(); - let mut unsupported_model_info = supported_model_info.clone(); - unsupported_model_info.input_modalities = vec![InputModality::Text]; - - let mut image_generation_disabled_features = Features::with_defaults(); - image_generation_disabled_features.disable(Feature::ImageGeneration); - let mut image_generation_features = Features::with_defaults(); - image_generation_features.enable(Feature::ImageGeneration); - - let available_models = Vec::new(); - let default_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_disabled_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let auth_disallowed_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &supported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: false, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let unsupported_tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &unsupported_model_info, - available_models: &available_models, - features: &image_generation_features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - assert!(!default_tools_config.image_gen_tool); - assert!(supported_tools_config.image_gen_tool); - assert!(!auth_disallowed_tools_config.image_gen_tool); - assert!(!unsupported_tools_config.image_gen_tool); -} - -#[test] -fn provider_capability_methods_disable_provider_bound_tool_surfaces() { - let model_info = model_info(); - let features = Features::with_defaults(); - let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - tools_config.search_tool = true; - tools_config.tool_suggest = true; - tools_config.image_gen_tool = true; - tools_config.namespace_tools = true; - - let tools_config = tools_config - .with_namespace_tools_capability(/*namespace_tools*/ false) - .with_image_generation_capability(/*image_generation*/ false) - .with_web_search_capability(/*web_search*/ false); - - assert!(tools_config.search_tool); - assert!(tools_config.tool_suggest); - assert!(!tools_config.image_gen_tool); - assert!(!tools_config.namespace_tools); - assert_eq!(tools_config.web_search_mode, None); } From 80fdd4688f6fa8143488c206d4c14dc193905254 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 12:19:46 +0200 Subject: [PATCH 102/114] Add `body_after_prefix` auto-compact token limit scope (#22870) ## Why `model_auto_compact_token_limit` has only been able to budget the full active context. That makes it hard to set a small "growth since compaction" budget for sessions that preserve a large carried window prefix: the preserved prefix can consume the whole budget and force immediate repeated compaction. This PR adds an opt-in `body_after_prefix` scope so callers can apply `model_auto_compact_token_limit` to sampled output and later growth after the current carried prefix, while still forcing compaction before the full model context window is exhausted. ## What changed - Adds `AutoCompactTokenLimitScope` with the existing `total` behavior as the default and a new `body_after_prefix` mode: [`config_types.rs`](https://github.com/openai/codex/blob/973806b1cb35792555bead994cb3ed94656eb171/codex-rs/protocol/src/config_types.rs#L24-L37). - Threads `model_auto_compact_token_limit_scope` through config loading, `Config`, `core-api`, and app-server v2 schema/TypeScript generation. - Records the first observed input-token count for a `body_after_prefix` compaction window and uses it as the baseline when deciding whether the scoped auto-compaction budget is exhausted: [`turn.rs`](https://github.com/openai/codex/blob/973806b1cb35792555bead994cb3ed94656eb171/codex-rs/core/src/session/turn.rs#L743-L781). - Keeps a hard context-window cap in `body_after_prefix`, so scoped budgeting cannot let the active context overrun the usable window. ## Verification Added compact-suite coverage for the two key behaviors: `body_after_prefix` does not re-compact just because the carried prefix is larger than the scoped budget, and it still compacts when the total active context reaches the configured context window: [`compact.rs`](https://github.com/openai/codex/blob/973806b1cb35792555bead994cb3ed94656eb171/codex-rs/core/tests/suite/compact.rs#L3003-L3128). --- .../codex_app_server_protocol.schemas.json | 29 ++ .../codex_app_server_protocol.v2.schemas.json | 29 ++ .../schema/json/v2/ConfigReadResponse.json | 29 ++ .../typescript/AutoCompactTokenLimitScope.ts | 9 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/Config.ts | 3 +- .../src/protocol/v2/config.rs | 2 + .../src/protocol/v2/tests.rs | 4 + codex-rs/config/src/config_toml.rs | 5 + codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/config.schema.json | 27 ++ codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 4 + codex-rs/core/src/config/mod.rs | 8 + codex-rs/core/src/guardian/review_session.rs | 31 ++ codex-rs/core/src/session/mod.rs | 55 ++- codex-rs/core/src/session/turn.rs | 201 +++++++--- .../core/src/state/auto_compact_window.rs | 145 ++++++++ codex-rs/core/src/state/mod.rs | 2 + codex-rs/core/src/state/session.rs | 26 ++ codex-rs/core/src/state/session_tests.rs | 19 + codex-rs/core/tests/suite/compact.rs | 342 ++++++++++++++++++ codex-rs/protocol/src/config_types.rs | 15 + codex-rs/thread-manager-sample/src/main.rs | 2 + 24 files changed, 933 insertions(+), 58 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts create mode 100644 codex-rs/core/src/state/auto_compact_window.rs 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 fdaaf3e857..c7f963df36 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 @@ -6314,6 +6314,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -7162,6 +7181,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ 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 e413d79234..0fee6fda8c 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 @@ -826,6 +826,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -3551,6 +3570,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 81364a6f40..7595f7fd00 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -188,6 +188,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "Config": { "additionalProperties": true, "properties": { @@ -280,6 +299,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts b/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts new file mode 100644 index 0000000000..3d9a56c82a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts @@ -0,0 +1,9 @@ +// 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. + +/** + * Selects which part of the active context is charged against + * `model_auto_compact_token_limit`. + */ +export type AutoCompactTokenLimitScope = "total" | "body_after_prefix"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 97ea435601..8be75af546 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -5,6 +5,7 @@ export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; export type { AuthMode } from "./AuthMode"; +export type { AutoCompactTokenLimitScope } from "./AutoCompactTokenLimitScope"; export type { ClientInfo } from "./ClientInfo"; export type { ClientNotification } from "./ClientNotification"; export type { ClientRequest } from "./ClientRequest"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index ba24663e87..29eae98774 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -1,6 +1,7 @@ // 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. +import type { AutoCompactTokenLimitScope } from "../AutoCompactTokenLimitScope"; import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; @@ -16,7 +17,7 @@ import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; -export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, /** +export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | null, model_provider: string | null, approval_policy: AskForApproval | null, /** * [UNSTABLE] Optional default for where approval requests are routed for * review. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index b46515d811..a34e6c530f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -3,6 +3,7 @@ use super::AskForApproval; use super::SandboxMode; use super::shared::default_enabled; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Verbosity; @@ -251,6 +252,7 @@ pub struct Config { pub review_model: Option, pub model_context_window: Option, pub model_auto_compact_token_limit: Option, + pub model_auto_compact_token_limit_scope: Option, pub model_provider: Option, #[experimental(nested)] pub approval_policy: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index b445fc4d01..fbc692c201 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1511,6 +1511,7 @@ fn config_granular_approval_policy_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: Some(AskForApproval::Granular { sandbox_approval: false, @@ -1551,6 +1552,7 @@ fn config_approvals_reviewer_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), @@ -1585,6 +1587,7 @@ fn config_nested_profile_granular_approval_policy_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: None, approvals_reviewer: None, @@ -1641,6 +1644,7 @@ fn config_nested_profile_approvals_reviewer_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: None, approvals_reviewer: None, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 76ba338fc0..43f75ffc28 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -38,6 +38,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -156,6 +157,10 @@ pub struct ConfigToml { /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, + /// Controls whether the auto-compaction limit applies to the full context or + /// only to tokens after the carried prefix in the current compaction window. + pub model_auto_compact_token_limit_scope: Option, + /// Default approval policy for executing commands. pub approval_policy: Option, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 04ebaf8e7e..e87ee82f30 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -58,6 +58,7 @@ pub use codex_models_manager::manager::SharedModelsManager; pub use codex_protocol::ThreadId; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::AutoCompactTokenLimitScope; pub use codex_protocol::config_types::CollaborationModeMask; pub use codex_protocol::config_types::ShellEnvironmentPolicy; pub use codex_protocol::config_types::WebSearchMode; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 07f49a3cd6..f0eb76d620 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -309,6 +309,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewToml": { "properties": { "policy": { @@ -4544,6 +4563,14 @@ "format": "int64", "type": "integer" }, + "model_auto_compact_token_limit_scope": { + "allOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + } + ], + "description": "Controls whether the auto-compaction limit applies to the full context or only to tokens after the carried prefix in the current compaction window." + }, "model_catalog_json": { "allOf": [ { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index cd1fcb6696..f604a63458 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -931,7 +931,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - fn reset_websocket_session(&mut self) { + pub(crate) fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 06ee405495..850d9ab571 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7711,6 +7711,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -8164,6 +8165,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, service_tier: None, model_provider_id: "openai-custom".to_string(), model_provider: fixture.openai_custom_provider.clone(), @@ -8331,6 +8333,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -8483,6 +8486,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, service_tier: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 74fdb8383c..dbd6cad470 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -77,6 +77,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -527,6 +528,10 @@ pub struct Config { /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, + /// Controls whether `model_auto_compact_token_limit` applies to the full + /// active context or only tokens after the carried compaction-window prefix. + pub model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope, + /// Key into the model_providers map that specifies which provider to use. pub model_provider_id: String, @@ -3373,6 +3378,9 @@ impl Config { review_model, model_context_window: cfg.model_context_window, model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, + model_auto_compact_token_limit_scope: cfg + .model_auto_compact_token_limit_scope + .unwrap_or_default(), model_provider_id, model_provider, cwd: resolved_cwd, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 389373c8de..15d6c142ca 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::anyhow; use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::PermissionProfile; @@ -138,6 +139,7 @@ struct GuardianReviewSessionReuseKey { model_provider: ModelProviderInfo, model_context_window: Option, model_auto_compact_token_limit: Option, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope, model_reasoning_effort: Option, model_reasoning_summary: Option, permissions: Permissions, @@ -162,6 +164,7 @@ impl GuardianReviewSessionReuseKey { model_provider: spawn_config.model_provider.clone(), model_context_window: spawn_config.model_context_window, model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit, + model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope, model_reasoning_effort: spawn_config.model_reasoning_effort, model_reasoning_summary: spawn_config.model_reasoning_summary, permissions: spawn_config.permissions.clone(), @@ -1155,6 +1158,34 @@ mod tests { ); } + #[tokio::test] + async fn guardian_review_session_compact_scope_change_invalidates_cached_session() { + let parent_config = crate::config::test_config().await; + let cached_spawn_config = build_guardian_review_session_config( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("cached guardian config"); + let cached_reuse_key = + GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config); + + let mut changed_parent_config = parent_config; + changed_parent_config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + let next_spawn_config = build_guardian_review_session_config( + &changed_parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("next guardian config"); + let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config); + + assert_ne!(cached_reuse_key, next_reuse_key); + } + #[tokio::test] async fn guardian_review_session_config_disables_hooks() { let mut parent_config = crate::config::test_config().await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3f1eab8936..ddab7752f7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -78,6 +78,7 @@ use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; @@ -288,6 +289,7 @@ use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; use crate::state::SessionState; @@ -1089,6 +1091,11 @@ impl Session { state.get_total_token_usage(state.server_reasoning_included()) } + pub(crate) async fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot { + let state = self.state.lock().await; + state.auto_compact_window_snapshot() + } + pub(crate) async fn get_total_token_usage_breakdown(&self) -> TotalTokenUsageBreakdown { let state = self.state.lock().await; state.history.get_total_token_usage_breakdown() @@ -1247,11 +1254,41 @@ impl Session { reconstructed_rollout.reference_context_item, ) .await; + let prefix_tokens = if matches!( + turn_context.config.model_auto_compact_token_limit_scope, + AutoCompactTokenLimitScope::BodyAfterPrefix + ) { + let history = self.clone_history().await; + let base_instructions = self.get_base_instructions().await; + history.estimate_token_count_with_base_instructions(&base_instructions) + } else { + None + }; + if let Some(prefix_tokens) = prefix_tokens { + self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens) + .await; + } self.set_previous_turn_settings(previous_turn_settings.clone()) .await; previous_turn_settings } + async fn set_auto_compact_window_estimated_prefill_for_scope( + &self, + turn_context: &TurnContext, + tokens: i64, + ) { + if !matches!( + turn_context.config.model_auto_compact_token_limit_scope, + AutoCompactTokenLimitScope::BodyAfterPrefix + ) { + return; + } + + let mut state = self.state.lock().await; + state.set_auto_compact_window_estimated_prefill(tokens); + } + fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option { rollout_items.iter().rev().find_map(|item| match item { RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(), @@ -2568,8 +2605,11 @@ impl Session { reference_context_item: Option, compacted_item: CompactedItem, ) { - self.replace_history(items, reference_context_item.clone()) - .await; + { + let mut state = self.state.lock().await; + state.replace_history(items, reference_context_item.clone()); + state.start_next_auto_compact_window(); + } self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)]) .await; @@ -2927,6 +2967,12 @@ impl Session { let mut state = self.state.lock().await; state .update_token_info_from_usage(token_usage, turn_context.model_context_window()); + if matches!( + turn_context.config.model_auto_compact_token_limit_scope, + AutoCompactTokenLimitScope::BodyAfterPrefix + ) { + state.ensure_auto_compact_window_server_prefill_from_usage(token_usage); + } state.token_info() }; if let Some(token_info) = token_info.as_ref() { @@ -2974,6 +3020,11 @@ impl Session { state.set_token_info(Some(info)); } + self.set_auto_compact_window_estimated_prefill_for_scope( + turn_context, + estimated_total_tokens, + ) + .await; self.send_token_count_event(turn_context).await; } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 5c4567b9fc..7b1a59f610 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -75,6 +75,7 @@ use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; use codex_hooks::HookResult; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ServiceTier; use codex_protocol::error::CodexErr; @@ -146,26 +147,31 @@ pub(crate) async fn run_turn( return None; } - let model_info = turn_context.model_info.clone(); - let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX); let mut client_session = prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session()); // TODO(ccunningham): Pre-turn compaction runs before context updates and the // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } - error!("Failed to run pre-sampling compact"); - return None; + let pre_sampling_compact = + match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + Ok(pre_sampling_compact) => pre_sampling_compact, + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + error!("Failed to run pre-sampling compact"); + return None; + } + }; + if pre_sampling_compact.reset_client_session { + client_session.reset_websocket_session(); } sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) @@ -342,17 +348,24 @@ pub(crate) async fn run_turn( can_drain_pending_input = true; let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await; let needs_follow_up = model_needs_follow_up || has_pending_input; - let total_usage_tokens = sess.get_total_token_usage().await; - let token_limit_reached = total_usage_tokens >= auto_compact_limit; + let token_status = + auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; + let token_limit_reached = token_status.token_limit_reached; let estimated_token_count = sess.get_estimated_token_count(turn_context.as_ref()).await; trace!( turn_id = %turn_context.sub_id, - total_usage_tokens, + total_usage_tokens = token_status.active_context_tokens, + auto_compact_scope_tokens = token_status.auto_compact_scope_tokens, estimated_token_count = ?estimated_token_count, - auto_compact_limit, + auto_compact_scope_limit = token_status.auto_compact_scope_limit, + auto_compact_limit_scope = ?turn_context.config.model_auto_compact_token_limit_scope, + auto_compact_window_ordinal = ?token_status.auto_compact_window_ordinal, + auto_compact_window_prefill_tokens = ?token_status.auto_compact_window_prefill_tokens, + full_context_window_limit = ?token_status.full_context_window_limit, + full_context_window_limit_reached = token_status.full_context_window_limit_reached, token_limit_reached, model_needs_follow_up, has_pending_input, @@ -362,7 +375,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - match run_auto_compact( + let reset_client_session = match run_auto_compact( &sess, &turn_context, &mut client_session, @@ -372,7 +385,7 @@ pub(crate) async fn run_turn( ) .await { - Ok(()) => {} + Ok(reset_client_session) => reset_client_session, Err(err) => { if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess @@ -388,6 +401,9 @@ pub(crate) async fn run_turn( return None; } }; + if reset_client_session { + client_session.reset_websocket_session(); + } can_drain_pending_input = !model_needs_follow_up; continue; } @@ -743,27 +759,88 @@ async fn track_turn_resolved_config_analytics( }); } +struct PreSamplingCompactResult { + reset_client_session: bool, +} + +#[derive(Debug)] +struct AutoCompactTokenStatus { + // Full active context usage, independent of the configured auto-compact scope. + active_context_tokens: i64, + // Usage counted against `model_auto_compact_token_limit` for the current scope. + auto_compact_scope_tokens: i64, + auto_compact_scope_limit: i64, + full_context_window_limit: Option, + auto_compact_window_ordinal: Option, + auto_compact_window_prefill_tokens: Option, + full_context_window_limit_reached: bool, + token_limit_reached: bool, +} + +async fn auto_compact_token_status( + sess: &Session, + turn_context: &TurnContext, +) -> AutoCompactTokenStatus { + let active_context_tokens = sess.get_total_token_usage().await; + let mut auto_compact_window_ordinal = None; + let mut auto_compact_window_prefill_tokens = None; + let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) = + match turn_context.config.model_auto_compact_token_limit_scope { + AutoCompactTokenLimitScope::Total => ( + active_context_tokens, + turn_context + .model_info + .auto_compact_token_limit() + .unwrap_or(i64::MAX), + None, + ), + AutoCompactTokenLimitScope::BodyAfterPrefix => { + let window = sess.auto_compact_window_snapshot().await; + auto_compact_window_ordinal = Some(window.ordinal); + auto_compact_window_prefill_tokens = window.prefill_input_tokens; + let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens); + ( + active_context_tokens.saturating_sub(baseline), + turn_context + .config + .model_auto_compact_token_limit + .or_else(|| turn_context.model_info.auto_compact_token_limit()) + .unwrap_or(i64::MAX), + turn_context.model_context_window(), + ) + } + }; + let full_context_window_limit_reached = + full_context_window_limit.is_some_and(|full_context_window_limit| { + active_context_tokens >= full_context_window_limit + }); + let token_limit_reached = + auto_compact_scope_tokens >= auto_compact_scope_limit || full_context_window_limit_reached; + + AutoCompactTokenStatus { + active_context_tokens, + auto_compact_scope_tokens, + auto_compact_scope_limit, + full_context_window_limit, + auto_compact_window_ordinal, + auto_compact_window_prefill_tokens, + full_context_window_limit_reached, + token_limit_reached, + } +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult<()> { - let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - maybe_run_previous_model_inline_compact( - sess, - turn_context, - client_session, - total_usage_tokens_before_compaction, - ) - .await?; - let total_usage_tokens = sess.get_total_token_usage().await; - let auto_compact_limit = turn_context - .model_info - .auto_compact_token_limit() - .unwrap_or(i64::MAX); - // Compact if the total usage tokens are greater than the auto compact limit - if total_usage_tokens >= auto_compact_limit { - run_auto_compact( +) -> CodexResult { + let mut pre_sampling_compacted = + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; + let mut reset_client_session = pre_sampling_compacted; + let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; + // Compact if the configured auto-compaction budget or usable context window is exhausted. + if token_status.token_limit_reached { + reset_client_session |= run_auto_compact( sess, turn_context, client_session, @@ -772,22 +849,26 @@ async fn run_pre_sampling_compact( CompactionPhase::PreTurn, ) .await?; + pre_sampling_compacted = true; } - Ok(()) + Ok(PreSamplingCompactResult { + reset_client_session: pre_sampling_compacted && reset_client_session, + }) } /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Err(_)` only when compaction was attempted and failed. +/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped +/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction +/// was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, - total_usage_tokens: i64, -) -> CodexResult<()> { +) -> CodexResult { let Some(previous_turn_settings) = sess.previous_turn_settings().await else { - return Ok(()); + return Ok(false); }; let previous_model_turn_context = Arc::new( turn_context @@ -796,20 +877,31 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_model_turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; - let new_auto_compact_limit = turn_context - .model_info - .auto_compact_token_limit() - .unwrap_or(i64::MAX); - let should_run = total_usage_tokens > new_auto_compact_limit + let active_context_tokens = sess.get_total_token_usage().await; + let previous_model_limit_reached = match turn_context + .config + .model_auto_compact_token_limit_scope + { + AutoCompactTokenLimitScope::Total => { + let new_auto_compact_limit = turn_context + .model_info + .auto_compact_token_limit() + .unwrap_or(i64::MAX); + active_context_tokens > new_auto_compact_limit + || active_context_tokens >= new_context_window + } + AutoCompactTokenLimitScope::BodyAfterPrefix => active_context_tokens >= new_context_window, + }; + let should_run = previous_model_limit_reached && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - run_auto_compact( + let _ = run_auto_compact( sess, &previous_model_turn_context, client_session, @@ -818,8 +910,9 @@ async fn maybe_run_previous_model_inline_compact( CompactionPhase::PreTurn, ) .await?; + return Ok(true); } - Ok(()) + Ok(false) } async fn run_auto_compact( @@ -829,7 +922,7 @@ async fn run_auto_compact( initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, -) -> CodexResult<()> { +) -> CodexResult { if should_use_remote_compact_task(turn_context.provider.info()) { if turn_context.features.enabled(Feature::RemoteCompactionV2) { run_inline_remote_auto_compact_task_v2( @@ -841,7 +934,7 @@ async fn run_auto_compact( phase, ) .await?; - return Ok(()); + return Ok(false); } run_inline_remote_auto_compact_task( Arc::clone(sess), @@ -861,7 +954,7 @@ async fn run_auto_compact( ) .await?; } - Ok(()) + Ok(true) } pub(super) fn collect_explicit_app_ids_from_skill_items( diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs new file mode 100644 index 0000000000..d1deb3c288 --- /dev/null +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -0,0 +1,145 @@ +use codex_protocol::protocol::TokenUsage; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AutoCompactWindowSnapshot { + pub(crate) ordinal: u64, + pub(crate) prefill_input_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AutoCompactWindowPrefill { + ServerObserved(i64), + Estimated(i64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct AutoCompactWindow { + ordinal: u64, + /// Absolute input-token baseline for the current compaction window. + /// + /// `body_after_prefix` subtracts this from later active-context usage. It is + /// not the growth itself; server-observed usage replaces estimated + /// resume/recompute baselines when available. + prefill_input_tokens: Option, +} + +impl AutoCompactWindow { + pub(super) fn new() -> Self { + Self { + ordinal: 1, + prefill_input_tokens: None, + } + } + + pub(super) fn clear_prefill(&mut self) { + self.prefill_input_tokens = None; + } + + pub(super) fn start_next(&mut self) { + self.ordinal = self.ordinal.saturating_add(1); + self.clear_prefill(); + } + + /// Records the request-input side of the first server usage sample. The + /// sampled output from that response is body growth and should remain + /// counted against the scoped auto-compact budget. + pub(super) fn ensure_server_observed_prefill_from_usage(&mut self, usage: &TokenUsage) { + if matches!( + self.prefill_input_tokens, + Some(AutoCompactWindowPrefill::ServerObserved(_)) + ) { + return; + } + + self.prefill_input_tokens = Some(AutoCompactWindowPrefill::ServerObserved( + usage.input_tokens.max(0), + )); + } + + pub(super) fn set_estimated_prefill(&mut self, tokens: i64) { + if matches!( + self.prefill_input_tokens, + Some(AutoCompactWindowPrefill::ServerObserved(_)) + ) { + return; + } + + self.prefill_input_tokens = Some(AutoCompactWindowPrefill::Estimated(tokens.max(0))); + } + + pub(super) fn snapshot(&self) -> AutoCompactWindowSnapshot { + let prefill_input_tokens = match self.prefill_input_tokens { + Some(AutoCompactWindowPrefill::ServerObserved(tokens)) + | Some(AutoCompactWindowPrefill::Estimated(tokens)) => Some(tokens), + None => None, + }; + AutoCompactWindowSnapshot { + ordinal: self.ordinal, + prefill_input_tokens, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn tracks_prefill_and_window_boundaries() { + let mut window = AutoCompactWindow::new(); + + assert_eq!( + window.snapshot(), + AutoCompactWindowSnapshot { + ordinal: 1, + prefill_input_tokens: None, + } + ); + + window.set_estimated_prefill(/*tokens*/ 150); + assert_eq!( + window.snapshot(), + AutoCompactWindowSnapshot { + ordinal: 1, + prefill_input_tokens: Some(150), + } + ); + + window.ensure_server_observed_prefill_from_usage(&TokenUsage { + input_tokens: 120, + total_tokens: 170, + ..Default::default() + }); + assert_eq!( + window.snapshot(), + AutoCompactWindowSnapshot { + ordinal: 1, + prefill_input_tokens: Some(120), + } + ); + + window.ensure_server_observed_prefill_from_usage(&TokenUsage { + input_tokens: 130, + total_tokens: 180, + ..Default::default() + }); + window.set_estimated_prefill(/*tokens*/ 90); + assert_eq!( + window.snapshot(), + AutoCompactWindowSnapshot { + ordinal: 1, + prefill_input_tokens: Some(120), + } + ); + + window.start_next(); + assert_eq!( + window.snapshot(), + AutoCompactWindowSnapshot { + ordinal: 2, + prefill_input_tokens: None, + } + ); + } +} diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 13f3bf6c86..3122ec5f25 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -1,7 +1,9 @@ +mod auto_compact_window; mod service; mod session; mod turn; +pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; pub(crate) use turn::ActiveTurn; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 7121a77524..0903d0d073 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -5,6 +5,8 @@ use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashSet; +use super::auto_compact_window::AutoCompactWindow; +use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; use crate::session::PreviousTurnSettings; use crate::session::session::SessionConfiguration; @@ -26,6 +28,8 @@ pub(crate) struct SessionState { /// model/realtime handling on subsequent regular turns (including full-context /// reinjection after resume or `/compact`). previous_turn_settings: Option, + /// Runtime accounting state for the active auto-compaction window. + auto_compact_window: AutoCompactWindow, /// Startup prewarmed session prepared during session initialization. pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, @@ -45,6 +49,7 @@ impl SessionState { server_reasoning_included: false, mcp_dependency_prompted: HashSet::new(), previous_turn_settings: None, + auto_compact_window: AutoCompactWindow::new(), startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, @@ -94,6 +99,7 @@ impl SessionState { self.history.replace(items); self.history .set_reference_context_item(reference_context_item); + self.auto_compact_window.clear_prefill(); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -117,6 +123,26 @@ impl SessionState { self.history.update_token_info(usage, model_context_window); } + pub(crate) fn ensure_auto_compact_window_server_prefill_from_usage( + &mut self, + usage: &TokenUsage, + ) { + self.auto_compact_window + .ensure_server_observed_prefill_from_usage(usage); + } + + pub(crate) fn set_auto_compact_window_estimated_prefill(&mut self, tokens: i64) { + self.auto_compact_window.set_estimated_prefill(tokens); + } + + pub(crate) fn start_next_auto_compact_window(&mut self) { + self.auto_compact_window.start_next(); + } + + pub(crate) fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot { + self.auto_compact_window.snapshot() + } + pub(crate) fn token_info(&self) -> Option { self.history.token_info() } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index 5e90cc881d..b6b5a057a0 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::session::tests::make_session_configuration_for_tests; +use crate::state::AutoCompactWindowSnapshot; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; @@ -61,6 +62,24 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { ); } +#[tokio::test] +async fn replace_history_clears_auto_compact_window_prefill_without_advancing() { + let session_configuration = make_session_configuration_for_tests().await; + let mut state = SessionState::new(session_configuration); + + state.start_next_auto_compact_window(); + state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100); + state.replace_history(Vec::new(), /*reference_context_item*/ None); + + assert_eq!( + state.auto_compact_window_snapshot(), + AutoCompactWindowSnapshot { + ordinal: 2, + prefill_input_tokens: None, + } + ); +} + #[tokio::test] async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() { let session_configuration = make_session_configuration_for_tests().await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b07adfc61c..140b4ec2b1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -7,6 +7,7 @@ use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelInfo; @@ -126,6 +127,22 @@ fn set_test_compact_prompt(config: &mut Config) { config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); } +fn ev_completed_with_usage(id: &str, input_tokens: i64, output_tokens: i64) -> Value { + json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": input_tokens, + "input_tokens_details": null, + "output_tokens": output_tokens, + "output_tokens_details": null, + "total_tokens": input_tokens + output_tokens + } + } + }) +} + fn body_contains_text(body: &str, text: &str) -> bool { body.contains(&json_fragment(text)) } @@ -2074,6 +2091,100 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn body_after_prefix_model_switch_budget_compacts_with_next_model() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_context_window(previous_model, /*context_window*/ 273_000), + model_info_with_context_window(next_model, /*context_window*/ 125_000), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_usage("r1", /*input_tokens*/ 100, /*output_tokens*/ 50), + ]), + sse(vec![ + ev_assistant_message("m2", "BODY_BUDGET_SUMMARY"), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.enable(Feature::RemoteModels); + config.model_auto_compact_token_limit = Some(20); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit second user turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 3, + "expected user, compact, and follow-up requests" + ); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(previous_model) + ); + assert_eq!(requests[1].body_json()["model"].as_str(), Some(next_model)); + assert_eq!(requests[2].body_json()["model"].as_str(), Some(next_model)); + assert!( + body_contains_text(&requests[1].body_json().to_string(), SUMMARIZATION_PROMPT), + "body-budget compaction request should include summarization prompt" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { skip_if_no_network!(); @@ -3011,6 +3122,237 @@ async fn auto_compact_clamps_config_limit_to_context_window() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_body_after_prefix_ignores_starting_window_prefix() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_usage("r1", /*input_tokens*/ 600, /*output_tokens*/ 50), + ]); + let second_turn = sse(vec![ + ev_assistant_message("m2", SECOND_LARGE_REPLY), + ev_completed_with_usage("r2", /*input_tokens*/ 700, /*output_tokens*/ 50), + ]); + let auto_compact_turn = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", /*total_tokens*/ 20), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_usage("r4", /*input_tokens*/ 750, /*output_tokens*/ 20), + ]); + let request_log = mount_sse_sequence( + &server, + vec![first_turn, second_turn, auto_compact_turn, third_turn], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(1_000); + config.model_auto_compact_token_limit = Some(100); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + }) + .build(&server) + .await + .expect("build codex"); + + for user in ["PREFIX_FREE_ONE", "PREFIX_FREE_TWO"] { + test.submit_turn(user).await.expect("submit turn"); + } + + assert_eq!( + request_log.requests().len(), + 2, + "the first two turns should not compact just because the prefix exceeds the body budget" + ); + + test.submit_turn("PREFIX_FREE_THREE") + .await + .expect("submit third turn"); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 4, + "third turn should include pre-turn compaction plus the post-compaction request" + ); + let compact_body = requests[2].body_json().to_string(); + assert!( + body_contains_text(&compact_body, SUMMARIZATION_PROMPT), + "body-after-prefix mode should compact once tokens after the first assistant sample exceed the configured budget" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_body_after_prefix_counts_growth_after_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_usage("r1", /*input_tokens*/ 100, /*output_tokens*/ 50), + ]); + let first_auto_compact_turn = sse(vec![ + ev_assistant_message("m2", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r2", /*total_tokens*/ 20), + ]); + let second_turn = sse(vec![ + ev_assistant_message("m3", SECOND_LARGE_REPLY), + ev_completed_with_usage( + "r3", /*input_tokens*/ 100_000, /*output_tokens*/ 10, + ), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_usage( + "r4", /*input_tokens*/ 100_100, /*output_tokens*/ 5, + ), + ]); + let second_auto_compact_turn = sse(vec![ + ev_assistant_message("m5", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r5", /*total_tokens*/ 20), + ]); + let fourth_turn = sse(vec![ + ev_assistant_message("m6", FINAL_REPLY), + ev_completed_with_usage("r6", /*input_tokens*/ 80, /*output_tokens*/ 5), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + first_auto_compact_turn, + second_turn, + third_turn, + second_auto_compact_turn, + fourth_turn, + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(200_000); + config.model_auto_compact_token_limit = Some(40); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + }) + .build(&server) + .await + .expect("build codex"); + + test.submit_turn("WINDOW_PREFIX") + .await + .expect("submit first turn"); + test.submit_turn("GROWTH_AFTER_COMPACT") + .await + .expect("submit second turn"); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 3, + "second turn should compact first and then sample the new growth" + ); + + test.submit_turn("AFTER_GROWTH") + .await + .expect("submit third turn"); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 4, + "the first server-observed input in the new window should become the prefill baseline" + ); + + test.submit_turn("AFTER_GROWTH_TRIGGER") + .await + .expect("submit fourth turn"); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 6, + "fourth turn should compact because later post-compaction growth counted against the body budget" + ); + let compact_body = requests[4].body_json().to_string(); + assert!( + body_contains_text(&compact_body, SUMMARIZATION_PROMPT), + "post-compaction growth should trigger a second body-after-prefix compaction" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_body_after_prefix_still_caps_at_context_window() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_usage("r1", /*input_tokens*/ 80, /*output_tokens*/ 5), + ]); + let second_turn = sse(vec![ + ev_assistant_message("m2", SECOND_LARGE_REPLY), + ev_completed_with_usage("r2", /*input_tokens*/ 98, /*output_tokens*/ 1), + ]); + let auto_compact_turn = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", /*total_tokens*/ 20), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_usage("r4", /*input_tokens*/ 80, /*output_tokens*/ 5), + ]); + let request_log = mount_sse_sequence( + &server, + vec![first_turn, second_turn, auto_compact_turn, third_turn], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(200); + config.model_auto_compact_token_limit_scope = + AutoCompactTokenLimitScope::BodyAfterPrefix; + }) + .build(&server) + .await + .expect("build codex"); + + for user in ["CONTEXT_CAP_ONE", "CONTEXT_CAP_TWO", "CONTEXT_CAP_THREE"] { + test.submit_turn(user).await.expect("submit turn"); + } + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 4, + "third turn should compact before sampling because total context hit the usable window" + ); + let compact_body = requests[2].body_json().to_string(); + assert!( + body_contains_text(&compact_body, SUMMARIZATION_PROMPT), + "body-after-prefix mode should still clamp the total threshold to the usable context window" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_counts_encrypted_reasoning_before_last_user() { skip_if_no_network!(); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 22c4f51548..b4b4759c9b 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -21,6 +21,21 @@ use wildmatch::WildMatchPattern; use crate::openai_models::ReasoningEffort; +/// Selects which part of the active context is charged against +/// `model_auto_compact_token_limit`. +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum AutoCompactTokenLimitScope { + /// Count the full active context against the limit. + #[default] + Total, + /// Count sampled output and later growth after the carried window prefix. + BodyAfterPrefix, +} + /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 3410711e3f..cf499c7703 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -15,6 +15,7 @@ use codex_core_api::Arg0DispatchPaths; use codex_core_api::AskForApproval; use codex_core_api::AuthCredentialsStoreMode; use codex_core_api::AuthManager; +use codex_core_api::AutoCompactTokenLimitScope; use codex_core_api::CodexThread; use codex_core_api::Config; use codex_core_api::ConfigLayerStack; @@ -168,6 +169,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, model_provider_id, model_provider, personality: None, From b3ae3de4056f9417c968f8628cde55b428f309b8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 15:04:35 +0200 Subject: [PATCH 103/114] Defer v1 multi-agent tools behind tool search (#23144) Summary: defer v1 multi-agent tools when tool_search and namespace tools are available; keep concise searchable descriptions and move the v1 usage guidance into developer instructions; add targeted coverage. Testing: not run per request; ran just fmt. --- ..._startup_context_then_first_turn_diff.snap | 1 - .../core/src/tools/handlers/multi_agents.rs | 19 ++++ .../handlers/multi_agents/close_agent.rs | 7 ++ .../handlers/multi_agents/resume_agent.rs | 7 ++ .../tools/handlers/multi_agents/send_input.rs | 7 ++ .../src/tools/handlers/multi_agents/spawn.rs | 7 ++ .../src/tools/handlers/multi_agents/wait.rs | 7 ++ codex-rs/core/src/tools/spec_plan.rs | 53 ++++++--- codex-rs/core/src/tools/spec_plan_tests.rs | 34 ++++++ .../tests/suite/collaboration_instructions.rs | 9 -- codex-rs/core/tests/suite/compact_remote.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 6 +- codex-rs/core/tests/suite/search_tool.rs | 101 +++++++++++++++++- .../tests/suite/subagent_notifications.rs | 79 ++++++++------ 14 files changed, 273 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap index f9a808f65e..c51cb93ac6 100644 --- a/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap +++ b/codex-rs/core/src/session/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -1,6 +1,5 @@ --- source: core/src/session/tests.rs -assertion_line: 1619 expression: snapshot --- Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode. diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 1587c4e840..d26b64722d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -18,6 +18,7 @@ pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; +use crate::tools::tool_search_entry::ToolSearchInfo; use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; @@ -34,10 +35,14 @@ use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolName; +use codex_tools::ToolSearchSourceInfo; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +const MULTI_AGENT_TOOL_SEARCH_SOURCE_NAME: &str = "Multi-agent tools"; +const MULTI_AGENT_TOOL_SEARCH_SOURCE_DESCRIPTION: &str = "Spawn and manage sub-agents."; + pub(crate) fn parse_agent_id_target(target: &str) -> Result { ThreadId::from_string(target).map_err(|err| { FunctionCallError::RespondToModel(format!("invalid agent id {target}: {err:?}")) @@ -59,6 +64,20 @@ pub(crate) fn parse_agent_id_targets( .collect() } +fn multi_agent_tool_search_info( + search_text: &str, + spec: codex_tools::ToolSpec, +) -> Option { + ToolSearchInfo::from_spec( + search_text.to_string(), + spec, + Some(ToolSearchSourceInfo { + name: MULTI_AGENT_TOOL_SEARCH_SOURCE_NAME.to_string(), + description: Some(MULTI_AGENT_TOOL_SEARCH_SOURCE_DESCRIPTION.to_string()), + }), + ) +} + pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; pub(crate) use send_input::Handler as SendInputHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 805f569b8e..ce8bd5d2ab 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -107,6 +107,13 @@ async fn handle_close_agent( } impl CoreToolRuntime for Handler { + fn search_info(&self) -> Option { + multi_agent_tool_search_info( + "close_agent close shutdown stop agent subagent thread status target", + self.spec()?, + ) + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 71b29fb423..fcb8aeab18 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -135,6 +135,13 @@ async fn handle_resume_agent( } impl CoreToolRuntime for Handler { + fn search_info(&self) -> Option { + multi_agent_tool_search_info( + "resume_agent resume reopen closed agent subagent thread id target", + self.spec()?, + ) + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 74f7eb05b4..3a42f425ec 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -91,6 +91,13 @@ impl ToolExecutor for Handler { } impl CoreToolRuntime for Handler { + fn search_info(&self) -> Option { + multi_agent_tool_search_info( + "send_input send message existing agent subagent follow up interrupt redirect queue target", + self.spec()?, + ) + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index d19849d443..ce87c9f08d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -200,6 +200,13 @@ async fn handle_spawn_agent( } impl CoreToolRuntime for Handler { + fn search_info(&self) -> Option { + multi_agent_tool_search_info( + "spawn_agent spawn agent subagent sub-agent delegate delegation parallel work worker explorer no-apps fork model reasoning", + self.spec()?, + ) + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index a69784b901..97841b3961 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -203,6 +203,13 @@ impl ToolExecutor for Handler { } impl CoreToolRuntime for Handler { + fn search_info(&self) -> Option { + multi_agent_tool_search_info( + "wait_agent wait agent subagent status final result complete timeout targets", + self.spec()?, + ) + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 26c91523cc..d75d56404d 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -610,23 +610,35 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } else { let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); - planned_tools.add_runtime(SpawnAgentHandler::new(SpawnAgentToolOptions { - available_models: turn_context.available_models.clone(), - agent_type_description, - hide_agent_type_model_reasoning: turn_context - .config - .multi_agent_v2 - .hide_spawn_agent_metadata, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, - usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), - max_concurrent_threads_per_session: max_concurrent_threads_per_session( - turn_context, - ), - })); - planned_tools.add_runtime(SendInputHandler); - planned_tools.add_runtime(ResumeAgentHandler); - planned_tools.add_runtime(WaitAgentHandler::new(context.wait_agent_timeouts)); - planned_tools.add_runtime(CloseAgentHandler); + let exposure = + if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }; + planned_tools.add_runtime_arc(multi_agent_v1_handler( + SpawnAgentHandler::new(SpawnAgentToolOptions { + available_models: turn_context.available_models.clone(), + agent_type_description, + hide_agent_type_model_reasoning: turn_context + .config + .multi_agent_v2 + .hide_spawn_agent_metadata, + include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, + usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), + max_concurrent_threads_per_session: max_concurrent_threads_per_session( + turn_context, + ), + }), + exposure, + )); + planned_tools.add_runtime_arc(multi_agent_v1_handler(SendInputHandler, exposure)); + planned_tools.add_runtime_arc(multi_agent_v1_handler(ResumeAgentHandler, exposure)); + planned_tools.add_runtime_arc(multi_agent_v1_handler( + WaitAgentHandler::new(context.wait_agent_timeouts), + exposure, + )); + planned_tools.add_runtime_arc(multi_agent_v1_handler(CloseAgentHandler, exposure)); } } @@ -757,6 +769,13 @@ fn append_extension_tool_executors( } } +fn multi_agent_v1_handler( + handler: impl CoreToolRuntime + 'static, + exposure: ToolExposure, +) -> Arc { + override_tool_exposure(Arc::new(handler), exposure) +} + fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 3c7c361e88..57bab6e39f 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -470,6 +470,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { missing_model_capability.assert_visible_lacks(&["tool_search"]); let missing_deferred_tools = probe(|turn| { + set_feature(turn, Feature::Collab, /*enabled*/ false); turn.model_info.supports_search_tool = true; }) .await; @@ -653,6 +654,39 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn v1_multi_agent_tools_defer_when_tool_search_available() { + let plan = probe(|turn| { + turn.model_info.supports_search_tool = true; + set_feature(turn, Feature::Collab, /*enabled*/ true); + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); + }) + .await; + + plan.assert_visible_contains(&["tool_search"]); + plan.assert_visible_lacks(&[ + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ]); + for tool_name in [ + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ] { + plan.assert_registered_contains(&[tool_name]); + assert_eq!(plan.exposure(tool_name), ToolExposure::Deferred); + } + let ToolSpec::ToolSearch { description, .. } = plan.visible_spec("tool_search") else { + panic!("expected visible tool_search spec"); + }; + assert!(description.contains("- Multi-agent tools: Spawn and manage sub-agents.")); +} + #[tokio::test] async fn multi_agent_v2_can_use_configured_tool_namespace() { let namespaced = probe(|turn| { diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bf8de53dd1..0f9bee9431 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -49,13 +49,6 @@ fn developer_texts(input: &[Value]) -> Vec { .collect() } -fn developer_message_count(input: &[Value]) -> usize { - input - .iter() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("developer")) - .count() -} - fn collab_xml(text: &str) -> String { format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") } @@ -92,7 +85,6 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let input = req.single_request().input(); - assert_eq!(developer_message_count(&input), 1); let dev_texts = developer_texts(&input); assert!( dev_texts @@ -790,7 +782,6 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let input = req.single_request().input(); - assert_eq!(developer_message_count(&input), 1); let dev_texts = developer_texts(&input); let collab_text = collab_xml(""); assert_eq!(count_messages_containing(&dev_texts, &collab_text), 0); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 9aac846ffe..d868abbfee 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1553,7 +1553,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result let override_base_instructions = format!( "{}\nREMOTE_BASE_INSTRUCTIONS_OVERRIDE {}", baseline_compact_request.instructions_text(), - "x".repeat(4_000) + "x".repeat(8_000) ); let override_context_window = baseline_payload_tokens.saturating_add(500); let pretrim_override_estimate = diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 68b3816ae6..a4b337f059 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -182,11 +182,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "request_user_input", "apply_patch", "view_image", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", + "tool_search", "web_search", ]); let body0 = req1.single_request().body_json(); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 8a092d7605..ca5c146e5a 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -217,7 +217,7 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { +async fn app_search_sources_are_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -249,8 +249,13 @@ async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { let body = mock.single_request().body_json(); let tools = tool_names(&body); assert!( - !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "tools list should not include {TOOL_SEARCH_TOOL_NAME} for API key auth: {tools:?}" + !tools.iter().any(|name| name == SEARCH_CALENDAR_NAMESPACE), + "tools list should not include app tools for API key auth: {tools:?}" + ); + let description = tool_search_description(&body).unwrap_or_default(); + assert!( + !description.contains("Calendar"), + "tool_search description should not include app sources for API key auth: {description}" ); Ok(()) @@ -671,6 +676,96 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_returns_deferred_v1_multi_agent_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "tool-search-spawn-agent"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_tool_search_call( + call_id, + &json!({ + "query": "spawn agent", + "limit": 1, + }), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex().with_config(configure_search_capable_model); + let test = builder.build(&server).await?; + test.submit_turn_with_approval_and_permission_profile( + "Find the spawn agent tool", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + + let first_request_body = requests[0].body_json(); + let first_request_tools = tool_names(&first_request_body); + assert!( + first_request_tools + .iter() + .any(|name| name == TOOL_SEARCH_TOOL_NAME), + "first request should advertise tool_search: {first_request_tools:?}" + ); + for tool_name in [ + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ] { + assert!( + !first_request_tools.iter().any(|name| name == tool_name), + "v1 multi-agent tools should be hidden before search: {first_request_tools:?}" + ); + } + assert!( + !first_request_body + .to_string() + .contains("Only use `spawn_agent` if and only if"), + "deferred v1 multi-agent guidance should stay out of initial developer context" + ); + + let tools = tool_search_output_tools(&requests[1], call_id); + let spawn_agent = tools + .iter() + .find(|tool| { + tool.get("type").and_then(Value::as_str) == Some("function") + && tool.get("name").and_then(Value::as_str) == Some("spawn_agent") + }) + .unwrap_or_else(|| panic!("expected tool_search to return spawn_agent: {tools:?}")); + assert_eq!( + spawn_agent.get("defer_loading").and_then(Value::as_bool), + Some(true) + ); + let description = spawn_agent + .get("description") + .and_then(Value::as_str) + .expect("spawn_agent description should be present"); + assert!(description.contains("Only use `spawn_agent` if and only if")); + assert!(description.contains("### Designing delegated subtasks")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3a0c37acc7..f56df240d5 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -9,8 +9,10 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_response_once_match; 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::responses::sse_response; use core_test_support::responses::start_mock_server; @@ -18,6 +20,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; @@ -64,28 +67,22 @@ fn has_subagent_notification(req: &ResponsesRequest) -> bool { .any(|text| text.contains("")) } -fn tool_parameter_description( - req: &ResponsesRequest, - tool_name: &str, - parameter_name: &str, -) -> Option { - req.body_json() +fn tool_parameter_description(tool: &Value, parameter_name: &str) -> Option { + tool.get("parameters") + .and_then(|parameters| parameters.get("properties")) + .and_then(|properties| properties.get(parameter_name)) + .and_then(|parameter| parameter.get("description")) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec { + request + .tool_search_output(call_id) .get("tools") - .and_then(serde_json::Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) { - tool.get("parameters") - .and_then(|parameters| parameters.get("properties")) - .and_then(|properties| properties.get(parameter_name)) - .and_then(|parameter| parameter.get("description")) - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - } else { - None - } - }) - }) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() } fn role_block(description: &str, role_name: &str) -> Option { @@ -646,14 +643,27 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let resp_mock = mount_sse_once_match( + let call_id = "tool-search-spawn-agent"; + let resp_mock = mount_sse_sequence( &server, - |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), - sse(vec![ - ev_response_created("resp-turn1-1"), - ev_assistant_message("msg-turn1-1", "done"), - ev_completed("resp-turn1-1"), - ]), + vec![ + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_tool_search_call( + call_id, + &json!({ + "query": "spawn agent custom role", + "limit": 1, + }), + ), + ev_completed("resp-turn1-1"), + ]), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "done"), + ev_completed("resp-turn1-2"), + ]), + ], ) .await; @@ -683,8 +693,17 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< test.submit_turn(TURN_1_PROMPT).await?; - let request = resp_mock.single_request(); - let agent_type_description = tool_parameter_description(&request, "spawn_agent", "agent_type") + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 2); + let tools = tool_search_output_tools(&requests[1], call_id); + let spawn_agent = tools + .iter() + .find(|tool| { + tool.get("type").and_then(Value::as_str) == Some("function") + && tool.get("name").and_then(Value::as_str) == Some("spawn_agent") + }) + .unwrap_or_else(|| panic!("expected tool_search to return spawn_agent: {tools:?}")); + let agent_type_description = tool_parameter_description(spawn_agent, "agent_type") .expect("spawn_agent agent_type description"); let custom_role_description = role_block(&agent_type_description, "custom").expect("custom role description"); From ef24ef127f9a0948a8a00381fcad8407906abba5 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 19 May 2026 08:39:45 -0700 Subject: [PATCH 104/114] [codex] Allow empty turn/start requests (#23409) ## Why `turn/start` already accepts an input array on the wire, including an empty array, but core treated empty input as a no-op before the turn could reach the model. App-server clients need to be able to start a real turn even when there is no new user message, for example to let the model proceed from existing thread context. ## What changed - Removed the `run_turn` early return that skipped empty-input turns when there was no pending input. - Kept empty active-turn steering rejected by moving the `steer_input` empty-input check until after core has determined whether there is an active regular turn. - Empty regular turns now refresh `previous_turn_settings` like other regular turns, so follow-up context injection state advances consistently. - Added an app-server v2 integration test proving `turn/start` with `input: []` emits started/completed notifications, sends one Responses request, and does not synthesize an empty user message. ## Validation - `cargo test -p codex-app-server --test all turn_start_with_empty_input_runs_model_request` --- .../app-server/tests/suite/v2/turn_start.rs | 101 ++++++++++++++++++ codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/turn.rs | 19 +--- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 62f0725ca1..156c0b2081 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -200,6 +200,107 @@ async fn received_response_input_images(server: &wiremock::MockServer) -> Result Ok(input_images) } +#[tokio::test] +async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: Vec::new(), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + let started_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(started_notif.params.expect("params must be present"))?; + assert_eq!(started.thread_id, thread.id); + assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.status, TurnStatus::InProgress); + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let response_requests = requests + .iter() + .filter(|request| request.url.path().ends_with("/responses")) + .collect::>(); + assert_eq!(response_requests.len(), 1); + let body = response_requests[0] + .body_json::() + .context("request body should be JSON")?; + let input = body + .get("input") + .and_then(Value::as_array) + .context("request body should include input array")?; + assert!( + !input.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + && item + .get("content") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + }), + "empty turn/start should not synthesize an empty user message: {input:?}" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_sends_originator_header() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ddab7752f7..9609a9d1b0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3143,10 +3143,6 @@ impl Session { expected_turn_id: Option<&str>, responsesapi_client_metadata: Option>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); - } - let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { return Err(SteerInputError::NoActiveTurn(input)); @@ -3180,6 +3176,10 @@ impl Session { None => return Err(SteerInputError::NoActiveTurn(input)), } + if input.is_empty() { + return Err(SteerInputError::EmptyInput); + } + if let Some(responsesapi_client_metadata) = responsesapi_client_metadata && let Some((_, active_task)) = active_turn.tasks.first() { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7b1a59f610..c4ad5ebbc8 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -143,10 +143,6 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() && !sess.input_queue.has_pending_input(&sess.active_turn).await { - return None; - } - let mut client_session = prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session()); // TODO(ccunningham): Pre-turn compaction runs before context updates and the @@ -210,16 +206,11 @@ pub(crate) async fn run_turn( sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; - if !input.is_empty() { - // Track the previous-turn baseline from the regular user-turn path only so - // standalone tasks (compact/shell/review) cannot suppress future - // model/realtime injections. - sess.set_previous_turn_settings(Some(PreviousTurnSettings { - model: turn_context.model_info.slug.clone(), - realtime_active: Some(turn_context.realtime_active), - })) - .await; - } + sess.set_previous_turn_settings(Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + })) + .await; for response_item in injection_items { sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item)) .await; From 9289b7cea8fcff0a3dfc3e472f09c816443b4355 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 19 May 2026 08:41:26 -0700 Subject: [PATCH 105/114] [codex] Move hook request plumbing into hook runtime (#23388) ## Why `run_turn` was still hand-building hook payloads and lifecycle events for a couple of hook paths. Most hook call sites already delegate request construction and event emission to `hook_runtime`, which keeps turn orchestration focused on model-flow decisions rather than hook plumbing. This also keeps the legacy `after_agent` message extraction next to the legacy hook dispatch instead of leaving response-item walking in `run_turn`. ## What changed - Added `run_stop_hooks` in `hook_runtime` to build `StopRequest`, emit preview start events, run the hook, and emit completion events. - Added `run_legacy_after_agent_hook` in `hook_runtime` to build and dispatch the legacy `AfterAgent` hook payload, including extracting input messages from response items. - Updated `run_turn` to call the hook runtime helpers and keep only the resulting continuation/block/stop decisions inline. - Removed the repeated pending session-start hook check from the run loop. ## Validation - `cargo test -p codex-core hook_runtime` --- codex-rs/core/src/hook_runtime.rs | 93 ++++++++++++++++++++++ codex-rs/core/src/session/turn.rs | 126 ++++-------------------------- 2 files changed, 110 insertions(+), 109 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 78fc24833e..da883b1e62 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -13,6 +13,8 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; +use codex_hooks::StopOutcome; +use codex_hooks::StopRequest; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -362,6 +364,97 @@ pub(crate) async fn run_user_prompt_submit_hooks( .await } +pub(crate) async fn run_stop_hooks( + sess: &Arc, + turn_context: &Arc, + stop_hook_active: bool, + last_assistant_message: Option, +) -> StopOutcome { + let request = StopRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + stop_hook_active, + last_assistant_message, + }; + let hooks = sess.hooks(); + emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await; + let mut outcome = hooks.run_stop(request).await; + emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await; + outcome +} + +pub(crate) async fn run_legacy_after_agent_hook( + sess: &Arc, + turn_context: &Arc, + input: &[ResponseItem], + last_assistant_message: Option, +) -> bool { + let mut abort_message = None; + let input_messages = input + .iter() + .filter_map(|item| match parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()), + _ => None, + }) + .collect(); + let hooks = sess.hooks(); + for hook_outcome in hooks + .dispatch(codex_hooks::HookPayload { + session_id: sess.session_id().into(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + client: turn_context.app_server_client_name.clone(), + triggered_at: chrono::Utc::now(), + hook_event: codex_hooks::HookEvent::AfterAgent { + event: codex_hooks::HookEventAfterAgent { + thread_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + input_messages, + last_assistant_message, + }, + }, + }) + .await + { + let hook_name = hook_outcome.hook_name; + let (error, should_abort) = match hook_outcome.result { + codex_hooks::HookResult::Success => continue, + codex_hooks::HookResult::FailedContinue(error) => (error, false), + codex_hooks::HookResult::FailedAbort(error) => (error, true), + }; + let action = if should_abort { + "aborting operation" + } else { + "continuing" + }; + tracing::warn!( + turn_id = %turn_context.sub_id, + hook_name = %hook_name, + error = %error, + "after_agent hook failed; {action}" + ); + if should_abort && abort_message.is_none() { + abort_message = Some(format!( + "after_agent hook '{hook_name}' failed and aborted turn completion: {error}" + )); + } + } + let Some(message) = abort_message else { + return false; + }; + let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent { + message, + codex_error_info: None, + }); + sess.send_event(turn_context, event).await; + true +} + pub(crate) async fn inspect_pending_input( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index c4ad5ebbc8..fd646631f6 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -19,11 +19,12 @@ use crate::context::ContextualUserFragment; use crate::feedback_tags; use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PendingInputHookDisposition; -use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; +use crate::hook_runtime::run_legacy_after_agent_hook; use crate::hook_runtime::run_pending_session_start_hooks; +use crate::hook_runtime::run_stop_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; @@ -35,7 +36,6 @@ use crate::mentions::build_skill_name_counts; use crate::mentions::collect_explicit_app_ids; use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; -use crate::parse_turn_item; use crate::plugins::build_plugin_injections; use crate::session::PreviousTurnSettings; use crate::session::session::Session; @@ -71,10 +71,6 @@ use codex_async_utils::OrCancelExt; use codex_features::Feature; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; -use codex_hooks::HookEvent; -use codex_hooks::HookEventAfterAgent; -use codex_hooks::HookPayload; -use codex_hooks::HookResult; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ServiceTier; @@ -91,7 +87,6 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; -use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; @@ -247,10 +242,6 @@ pub(crate) async fn run_turn( let mut can_drain_pending_input = input.is_empty(); loop { - if run_pending_session_start_hooks(&sess, &turn_context).await { - break; - } - // Note that pending_input would be something like a message the user // submitted through the UI while the model was running. Though the UI // may support this, the model might not. @@ -310,14 +301,6 @@ pub(crate) async fn run_turn( .for_prompt(&turn_context.model_info.input_modalities) }; - let sampling_request_input_messages = sampling_request_input - .iter() - .filter_map(|item| match parse_turn_item(item) { - Some(TurnItem::UserMessage(user_message)) => Some(user_message), - _ => None, - }) - .map(|user_message| user_message.message()) - .collect::>(); let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); match run_sampling_request( Arc::clone(&sess), @@ -326,7 +309,7 @@ pub(crate) async fn run_turn( Arc::clone(&turn_diff_tracker), &mut client_session, turn_metadata_header.as_deref(), - sampling_request_input, + sampling_request_input.clone(), cancellation_token.child_token(), ) .await @@ -401,39 +384,13 @@ pub(crate) async fn run_turn( if !needs_follow_up { last_agent_message = sampling_request_last_agent_message; - let stop_hook_permission_mode = match turn_context.approval_policy.value() { - AskForApproval::Never => "bypassPermissions", - AskForApproval::UnlessTrusted - | AskForApproval::OnFailure - | AskForApproval::OnRequest - | AskForApproval::Granular(_) => "default", - } - .to_string(); - let stop_request = codex_hooks::StopRequest { - session_id: sess.session_id().into(), - turn_id: turn_context.sub_id.clone(), - #[allow(deprecated)] - cwd: turn_context.cwd.clone(), - transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), - permission_mode: stop_hook_permission_mode, + let stop_outcome = run_stop_hooks( + &sess, + &turn_context, stop_hook_active, - last_assistant_message: last_agent_message.clone(), - }; - let hooks = sess.hooks(); - for run in hooks.preview_stop(&stop_request) { - sess.send_event( - &turn_context, - EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { - turn_id: Some(turn_context.sub_id.clone()), - run, - }), - ) - .await; - } - let stop_outcome = hooks.run_stop(stop_request).await; - emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) - .await; + last_agent_message.clone(), + ) + .await; if stop_outcome.should_block { if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) @@ -458,63 +415,14 @@ pub(crate) async fn run_turn( if stop_outcome.should_stop { break; } - let hook_outcomes = sess - .hooks() - .dispatch(HookPayload { - session_id: sess.session_id().into(), - #[allow(deprecated)] - cwd: turn_context.cwd.clone(), - client: turn_context.app_server_client_name.clone(), - triggered_at: chrono::Utc::now(), - hook_event: HookEvent::AfterAgent { - event: HookEventAfterAgent { - thread_id: sess.conversation_id, - turn_id: turn_context.sub_id.clone(), - input_messages: sampling_request_input_messages, - last_assistant_message: last_agent_message.clone(), - }, - }, - }) - .await; - - let mut abort_message = None; - for hook_outcome in hook_outcomes { - let hook_name = hook_outcome.hook_name; - match hook_outcome.result { - HookResult::Success => {} - HookResult::FailedContinue(error) => { - warn!( - turn_id = %turn_context.sub_id, - hook_name = %hook_name, - error = %error, - "after_agent hook failed; continuing" - ); - } - HookResult::FailedAbort(error) => { - let message = format!( - "after_agent hook '{hook_name}' failed and aborted turn completion: {error}" - ); - warn!( - turn_id = %turn_context.sub_id, - hook_name = %hook_name, - error = %error, - "after_agent hook failed; aborting operation" - ); - if abort_message.is_none() { - abort_message = Some(message); - } - } - } - } - if let Some(message) = abort_message { - sess.send_event( - &turn_context, - EventMsg::Error(ErrorEvent { - message, - codex_error_info: None, - }), - ) - .await; + if run_legacy_after_agent_hook( + &sess, + &turn_context, + &sampling_request_input, + last_agent_message.clone(), + ) + .await + { return None; } break; From f0663fd4fd531296bf34684e63e3ea029676c924 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 19 May 2026 09:47:43 -0700 Subject: [PATCH 106/114] [codex] Preserve steer input as user input (#23405) ## Why Steered input was queued as a `ResponseInputItem`, then parsed back into a user message before recording. That path loses information that only exists on `UserInput`, such as UI text elements. This change keeps turn-local pending input typed as either original `UserInput` or existing response items, so steered user input reaches user-message recording without being reconstructed from a response item. ## What changed - Add `TurnInput` for active-turn pending input. - Queue `Session::steer_input` as `TurnInput::UserInput`. - Run pending-input hook inspection only for `TurnInput::UserInput`. - Process drained pending input item by item: accepted items are recorded, blocked items append hook context and are skipped. - Remove the pending-input prepend/requeue path. ## Validation - `just fmt` - `just fix -p codex-core` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --lib session::tests::task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input -- --nocapture` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --lib steer_input` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --lib pending_input` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --test all pending_input` - `RUST_MIN_STACK=16777216 cargo test -p codex-core` (unit tests passed: 1835 passed, 0 failed, 4 ignored; integration `all` target failed due missing helper binaries such as `codex`/`test_stdio_server` plus unrelated MCP/search/code-mode expectations) --- codex-rs/core/src/goals.rs | 10 +- codex-rs/core/src/hook_runtime.rs | 115 +++++++------------- codex-rs/core/src/session/input_queue.rs | 66 +++++------- codex-rs/core/src/session/mod.rs | 5 +- codex-rs/core/src/session/tests.rs | 128 +++++++---------------- codex-rs/core/src/session/turn.rs | 88 ++++++---------- codex-rs/core/src/tasks/mod.rs | 36 ++++--- 7 files changed, 167 insertions(+), 281 deletions(-) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 4b3e237d53..73e82fdcfd 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -7,6 +7,7 @@ use crate::StateDbHandle; use crate::context::ContextualUserFragment; use crate::context::GoalContext; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; @@ -1347,7 +1348,14 @@ impl Session { return; } self.input_queue - .extend_pending_input_for_turn_state(turn_state.as_ref(), candidate.items) + .extend_pending_input_for_turn_state( + turn_state.as_ref(), + candidate + .items + .into_iter() + .map(TurnInput::ResponseInputItem) + .collect(), + ) .await; let turn_context = self diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index da883b1e62..8cba0f9fad 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -20,7 +20,7 @@ use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; use codex_otel::HOOK_RUN_METRIC; use codex_protocol::items::TurnItem; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::items::UserMessageItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -30,12 +30,12 @@ use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookStartedEvent; -use codex_protocol::user_input::UserInput; use serde_json::Value; use crate::context::ContextualUserFragment; use crate::context::HookAdditionalContext; use crate::event_mapping::parse_turn_item; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::hook_names::HookToolName; @@ -51,22 +51,6 @@ pub(crate) enum PreToolUseHookResult { Blocked(String), } -pub(crate) enum PendingInputHookDisposition { - Accepted(Box), - Blocked { additional_contexts: Vec }, -} - -pub(crate) enum PendingInputRecord { - UserMessage { - content: Vec, - response_item: ResponseItem, - additional_contexts: Vec, - }, - ConversationItem { - response_item: ResponseItem, - }, -} - struct ContextInjectingHookOutcome { hook_events: Vec, outcome: HookRuntimeOutcome, @@ -338,32 +322,6 @@ pub(crate) async fn run_post_compact_hooks( } } -pub(crate) async fn run_user_prompt_submit_hooks( - sess: &Arc, - turn_context: &Arc, - prompt: String, -) -> HookRuntimeOutcome { - let request = UserPromptSubmitRequest { - session_id: sess.session_id().into(), - turn_id: turn_context.sub_id.clone(), - #[allow(deprecated)] - cwd: turn_context.cwd.clone(), - transcript_path: sess.hook_transcript_path().await, - model: turn_context.model_info.slug.clone(), - permission_mode: hook_permission_mode(turn_context), - prompt, - }; - let hooks = sess.hooks(); - let preview_runs = hooks.preview_user_prompt_submit(&request); - run_context_injecting_hook( - sess, - turn_context, - preview_runs, - hooks.run_user_prompt_submit(request), - ) - .await -} - pub(crate) async fn run_stop_hooks( sess: &Arc, turn_context: &Arc, @@ -458,54 +416,55 @@ pub(crate) async fn run_legacy_after_agent_hook( pub(crate) async fn inspect_pending_input( sess: &Arc, turn_context: &Arc, - pending_input_item: ResponseInputItem, -) -> PendingInputHookDisposition { - let response_item = ResponseItem::from(pending_input_item); - if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) { - let user_prompt_submit_outcome = - run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await; - if user_prompt_submit_outcome.should_stop { - PendingInputHookDisposition::Blocked { - additional_contexts: user_prompt_submit_outcome.additional_contexts, - } - } else { - PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage { - content: user_message.content, - response_item, - additional_contexts: user_prompt_submit_outcome.additional_contexts, - })) + pending_input_item: &TurnInput, +) -> HookRuntimeOutcome { + match pending_input_item { + TurnInput::UserInput(content) => { + let request = UserPromptSubmitRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + prompt: UserMessageItem::new(content).message(), + }; + let hooks = sess.hooks(); + let preview_runs = hooks.preview_user_prompt_submit(&request); + run_context_injecting_hook( + sess, + turn_context, + preview_runs, + hooks.run_user_prompt_submit(request), + ) + .await } - } else { - PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem { - response_item, - })) + TurnInput::ResponseInputItem(_) => HookRuntimeOutcome { + should_stop: false, + additional_contexts: Vec::new(), + }, } } pub(crate) async fn record_pending_input( sess: &Arc, turn_context: &Arc, - pending_input: PendingInputRecord, + pending_input: TurnInput, + additional_contexts: Vec, ) { match pending_input { - PendingInputRecord::UserMessage { - content, - response_item, - additional_contexts, - } => { - sess.record_user_prompt_and_emit_turn_item( - turn_context.as_ref(), - content.as_slice(), - response_item, - ) - .await; - record_additional_contexts(sess, turn_context, additional_contexts).await; + TurnInput::UserInput(content) => { + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice()) + .await; } - PendingInputRecord::ConversationItem { response_item } => { + TurnInput::ResponseInputItem(input) => { + let response_item = ResponseItem::from(input); sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; } } + record_additional_contexts(sess, turn_context, additional_contexts).await; } async fn run_context_injecting_hook( diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 2e8a6ded9d..5f92322c8d 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -3,15 +3,22 @@ use crate::state::MailboxDeliveryPhase; use crate::state::TurnState; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::user_input::UserInput; use std::collections::VecDeque; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::watch; +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TurnInput { + UserInput(Vec), + ResponseInputItem(ResponseInputItem), +} + /// Turn-local pending input storage owned by the input queue flow. #[derive(Default)] pub(crate) struct TurnInputQueue { - items: Vec, + items: Vec, } /// Session-scoped pending input storage and active-turn mailbox delivery coordination. @@ -151,7 +158,7 @@ impl InputQueue { pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state( &self, turn_state: &Mutex, - input: ResponseInputItem, + input: TurnInput, ) { let mut turn_state = turn_state.lock().await; turn_state.pending_input.items.push(input); @@ -161,7 +168,7 @@ impl InputQueue { pub(crate) async fn extend_pending_input_for_turn_state( &self, turn_state: &Mutex, - input: Vec, + input: Vec, ) { turn_state.lock().await.pending_input.items.extend(input); } @@ -169,7 +176,7 @@ impl InputQueue { pub(crate) async fn take_pending_input_for_turn_state( &self, turn_state: &Mutex, - ) -> Vec { + ) -> Vec { turn_state.lock().await.pending_input.items.split_off(0) } @@ -185,43 +192,20 @@ impl InputQueue { let mut active = active_turn.lock().await; match active.as_mut() { Some(active_turn) => { - active_turn - .turn_state - .lock() - .await - .pending_input - .items - .extend(input); + self.extend_pending_input_for_turn_state( + active_turn.turn_state.as_ref(), + input + .into_iter() + .map(TurnInput::ResponseInputItem) + .collect(), + ) + .await; Ok(()) } None => Err(input), } } - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub(crate) async fn prepend_pending_input( - &self, - active_turn: &Mutex>, - mut input: Vec, - ) -> Result<(), ()> { - let mut active = active_turn.lock().await; - match active.as_mut() { - Some(active_turn) => { - let mut turn_state = active_turn.turn_state.lock().await; - if !input.is_empty() { - let pending_input = &mut turn_state.pending_input; - input.append(&mut pending_input.items); - pending_input.items = input; - } - Ok(()) - } - None => Err(()), - } - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -229,7 +213,7 @@ impl InputQueue { pub(crate) async fn get_pending_input( &self, active_turn: &Mutex>, - ) -> Vec { + ) -> Vec { let (pending_input, accepts_mailbox_delivery) = { let mut active = active_turn.lock().await; match active.as_mut() { @@ -246,11 +230,13 @@ impl InputQueue { if !accepts_mailbox_delivery { return pending_input; } - let mailbox_items = self.drain_mailbox_input_items().await; + let mailbox_items = self + .drain_mailbox_input_items() + .await + .into_iter() + .map(TurnInput::ResponseInputItem); if pending_input.is_empty() { - mailbox_items - } else if mailbox_items.is_empty() { - pending_input + mailbox_items.collect() } else { let mut pending_input = pending_input; pending_input.extend(mailbox_items); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9609a9d1b0..8f0d2f1c01 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -207,6 +207,7 @@ use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] use self::handlers::submission_dispatch_span; use self::handlers::submission_loop; +pub(crate) use self::input_queue::TurnInput; pub(crate) use self::input_queue::TurnInputQueue; use self::review::spawn_review_thread; use self::session::AppServerClientMetadata; @@ -3099,11 +3100,11 @@ impl Session { &self, turn_context: &TurnContext, input: &[UserInput], - response_item: ResponseItem, ) { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. + let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec())); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; let turn_item = TurnItem::UserMessage(UserMessageItem::new(input)); @@ -3192,7 +3193,7 @@ impl Session { self.input_queue .push_pending_input_and_accept_mailbox_delivery_for_turn_state( active_turn.turn_state.as_ref(), - input.into(), + TurnInput::UserInput(input), ) .await; Ok(active_turn_id.clone()) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f0cdc70fdf..c41d5e348e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7724,15 +7724,21 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() while rx.try_recv().is_ok() {} - sess.inject_response_items(vec![ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "late pending input".to_string(), - }], - phase: None, - }]) + let text_element = codex_protocol::user_input::TextElement::new( + codex_protocol::user_input::ByteRange { start: 5, end: 12 }, + Some("pending marker".to_string()), + ); + let pending_user_input = vec![UserInput::Text { + text: "late pending input".to_string(), + text_elements: vec![text_element.clone()], + }]; + sess.steer_input( + pending_user_input.clone(), + Some(&tc.sub_id), + /*responsesapi_client_metadata*/ None, + ) .await - .expect("inject pending input into active turn"); + .expect("steer pending input into active turn"); sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) .await; @@ -7766,10 +7772,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::ItemStarted(ItemStartedEvent { item: TurnItem::UserMessage(UserMessageItem { content, .. }), .. - }) if content == vec![UserInput::Text { - text: "late pending input".to_string(), - text_elements: Vec::new(), - }] + }) if content == pending_user_input )); let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) @@ -7781,10 +7784,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::ItemCompleted(ItemCompletedEvent { item: TurnItem::UserMessage(UserMessageItem { content, .. }), .. - }) if content == vec![UserInput::Text { - text: "late pending input".to_string(), - text_elements: Vec::new(), - }] + }) if content == pending_user_input )); let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) @@ -7801,7 +7801,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .. }) if message == "late pending input" && images == Some(Vec::new()) - && text_elements.is_empty() + && text_elements == vec![text_element] && local_images.is_empty() )); @@ -7954,69 +7954,6 @@ async fn steer_input_returns_active_turn_id() { assert!(sess.input_queue.has_pending_input(&sess.active_turn).await); } -#[tokio::test] -async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }]; - sess.spawn_task( - Arc::clone(&tc), - input, - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let blocked = ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "blocked queued prompt".to_string(), - }], - phase: None, - }; - let later = ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "later queued prompt".to_string(), - }], - phase: None, - }; - let newer = ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "newer queued prompt".to_string(), - }], - phase: None, - }; - - sess.inject_response_items(vec![blocked.clone(), later.clone()]) - .await - .expect("inject initial pending input into active turn"); - - let drained = sess.input_queue.get_pending_input(&sess.active_turn).await; - assert_eq!(drained, vec![blocked, later.clone()]); - - sess.inject_response_items(vec![newer.clone()]) - .await - .expect("inject newer pending input into active turn"); - - let mut drained_iter = drained.into_iter(); - let _blocked = drained_iter.next().expect("blocked prompt should exist"); - sess.input_queue - .prepend_pending_input(&sess.active_turn, drained_iter.collect()) - .await - .expect("requeue later pending input at the front of the queue"); - - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![later, newer] - ); -} - #[tokio::test] async fn queued_response_items_for_next_turn_move_into_next_active_turn() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; @@ -8044,7 +7981,7 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![queued_item] + vec![TurnInput::ResponseInputItem(queued_item)] ); } @@ -8089,7 +8026,10 @@ async fn abort_empty_active_turn_preserves_pending_input() { Arc::clone(&active_turn.turn_state) }; sess.input_queue - .extend_pending_input_for_turn_state(turn_state.as_ref(), vec![pending_item.clone()]) + .extend_pending_input_for_turn_state( + turn_state.as_ref(), + vec![TurnInput::ResponseInputItem(pending_item.clone())], + ) .await; sess.abort_all_tasks(TurnAbortReason::Replaced).await; @@ -8099,7 +8039,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { sess.input_queue .take_pending_input_for_turn_state(turn_state.as_ref()) .await, - vec![pending_item] + vec![TurnInput::ResponseInputItem(pending_item)] ); } @@ -8523,7 +8463,9 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { + let [TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. })] = + pending_input.as_slice() + else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; assert_eq!("user", role); @@ -8748,7 +8690,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { pending_input.iter().any(|item| { matches!( item, - ResponseInputItem::Message { role, content, .. } + TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. }) if role == "user" && content.iter().any(|content| matches!( content, @@ -8971,7 +8913,9 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![communication.to_response_input_item()], + vec![TurnInput::ResponseInputItem( + communication.to_response_input_item() + )], ); } @@ -9051,11 +8995,11 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ - ResponseInputItem::from(vec![UserInput::Text { + TurnInput::UserInput(vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), }]), - communication.to_response_input_item(), + TurnInput::ResponseInputItem(communication.to_response_input_item()), ], ); } @@ -9104,11 +9048,11 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ - ResponseInputItem::from(vec![UserInput::Text { + TurnInput::UserInput(vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), }]), - communication.to_response_input_item(), + TurnInput::ResponseInputItem(communication.to_response_input_item()), ], ); } @@ -9163,7 +9107,9 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { assert!(output.tool_future.is_some()); assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![communication.to_response_input_item()], + vec![TurnInput::ResponseInputItem( + communication.to_response_input_item() + )], ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index fd646631f6..4ac511f4fa 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -18,14 +18,12 @@ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; use crate::goals::GoalRuntimeEvent; -use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_legacy_after_agent_hook; use crate::hook_runtime::run_pending_session_start_hooks; use crate::hook_runtime::run_stop_hooks; -use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; use crate::injection::tool_kind_for_path; @@ -38,6 +36,7 @@ use crate::mentions::collect_explicit_plugin_mentions; use crate::mentions::collect_tool_mentions_from_messages; use crate::plugins::build_plugin_injections; use crate::session::PreviousTurnSettings; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::HandleOutputCtx; @@ -78,7 +77,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; -use codex_protocol::items::UserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -174,17 +172,10 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { return None; } - let additional_contexts = if input.is_empty() { - Vec::new() - } else { - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - let user_prompt_submit_outcome = run_user_prompt_submit_hooks( - &sess, - &turn_context, - UserMessageItem::new(&input).message(), - ) - .await; + if !input.is_empty() { + let initial_turn_input = TurnInput::UserInput(input.clone()); + let user_prompt_submit_outcome = + inspect_pending_input(&sess, &turn_context, &initial_turn_input).await; if user_prompt_submit_outcome.should_stop { record_additional_contexts( &sess, @@ -194,13 +185,17 @@ pub(crate) async fn run_turn( .await; return None; } - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; - user_prompt_submit_outcome.additional_contexts - }; + record_pending_input( + &sess, + &turn_context, + initial_turn_input, + user_prompt_submit_outcome.additional_contexts, + ) + .await; + } + sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - record_additional_contexts(&sess, &turn_context, additional_contexts).await; sess.set_previous_turn_settings(Some(PreviousTurnSettings { model: turn_context.model_info.slug.clone(), realtime_active: Some(turn_context.realtime_active), @@ -252,45 +247,26 @@ pub(crate) async fn run_turn( }; let mut blocked_pending_input = false; - let mut blocked_pending_input_contexts = Vec::new(); - let mut requeued_pending_input = false; - let mut accepted_pending_input = Vec::new(); - if !pending_input.is_empty() { - let mut pending_input_iter = pending_input.into_iter(); - while let Some(pending_input_item) = pending_input_iter.next() { - match inspect_pending_input(&sess, &turn_context, pending_input_item).await { - PendingInputHookDisposition::Accepted(pending_input) => { - accepted_pending_input.push(*pending_input); - } - PendingInputHookDisposition::Blocked { - additional_contexts, - } => { - let remaining_pending_input = pending_input_iter.collect::>(); - if !remaining_pending_input.is_empty() { - let _ = sess - .input_queue - .prepend_pending_input(&sess.active_turn, remaining_pending_input) - .await; - requeued_pending_input = true; - } - blocked_pending_input_contexts = additional_contexts; - blocked_pending_input = true; - break; - } - } + let mut accepted_pending_input = false; + for pending_input_item in pending_input { + let hook_outcome = + inspect_pending_input(&sess, &turn_context, &pending_input_item).await; + if hook_outcome.should_stop { + blocked_pending_input = true; + record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts) + .await; + } else { + accepted_pending_input = true; + record_pending_input( + &sess, + &turn_context, + pending_input_item, + hook_outcome.additional_contexts, + ) + .await; } } - - let has_accepted_pending_input = !accepted_pending_input.is_empty(); - for pending_input in accepted_pending_input { - record_pending_input(&sess, &turn_context, pending_input).await; - } - record_additional_contexts(&sess, &turn_context, blocked_pending_input_contexts).await; - - if blocked_pending_input && !has_accepted_pending_input { - if requeued_pending_input { - continue; - } + if blocked_pending_input && !accepted_pending_input { break; } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ba7f707927..164e65f07c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -24,10 +24,10 @@ use tracing::warn; use crate::config::Config; use crate::context::ContextualUserFragment; use crate::goals::GoalRuntimeEvent; -use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; @@ -42,7 +42,6 @@ use codex_otel::TURN_MEMORY_METRIC; use codex_otel::TURN_NETWORK_PROXY_METRIC; use codex_otel::TURN_TOKEN_USAGE_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; @@ -359,7 +358,10 @@ impl Session { Arc::clone(&turn.turn_state) }; turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; - let mut pending_items = queued_response_items; + let mut pending_items = queued_response_items + .into_iter() + .map(TurnInput::ResponseInputItem) + .collect::>(); pending_items.extend(mailbox_items); self.input_queue .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) @@ -587,7 +589,7 @@ impl Session { .turn_metadata_state .cancel_git_enrichment_task(); - let mut pending_input = Vec::::new(); + let mut pending_input = Vec::::new(); let mut should_clear_active_turn = false; let mut token_usage_at_turn_start = None; let mut turn_had_memory_citation = false; @@ -622,15 +624,23 @@ impl Session { } if !pending_input.is_empty() { for pending_input_item in pending_input { - match inspect_pending_input(self, &turn_context, pending_input_item).await { - PendingInputHookDisposition::Accepted(pending_input) => { - record_pending_input(self, &turn_context, *pending_input).await; - } - PendingInputHookDisposition::Blocked { - additional_contexts, - } => { - record_additional_contexts(self, &turn_context, additional_contexts).await; - } + let hook_outcome = + inspect_pending_input(self, &turn_context, &pending_input_item).await; + if hook_outcome.should_stop { + record_additional_contexts( + self, + &turn_context, + hook_outcome.additional_contexts, + ) + .await; + } else { + record_pending_input( + self, + &turn_context, + pending_input_item, + hook_outcome.additional_contexts, + ) + .await; } } } From ae10708ae030c294905af9f0e747f4d281c6048a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 10:21:07 -0700 Subject: [PATCH 107/114] [2 of 4] tui: route app and skill enablement through app server (#22914) ## Why App and skill toggles are user config mutations too. When the TUI is attached to a remote app server, writing those toggles into the local `config.toml` makes the UI report success without updating the server that actually owns the session. This is **[2 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Routed app enable/disable persistence through app-server config batch writes. - Routed skill enable/disable persistence through `skills/config/write`. - Avoided refreshing local config from disk after these writes when the TUI is connected to a remote app server. ## Config keys affected - `apps..enabled` - `apps..disabled_reason` - `[[skills.config]]` entries keyed by `path`, with `enabled = false` used for persisted disables ## Suggested manual validation - Connect the TUI to a remote app server, disable an app, reconnect, and confirm the app remains disabled from remote config rather than local disk state. - Re-enable the same app and confirm both `apps..enabled` and `apps..disabled_reason` are cleared remotely. - Disable a skill in the manage-skills UI and confirm a remote `[[skills.config]]` disable entry appears. - Re-enable that skill and confirm the disable entry is removed and the effective enabled state updates without relying on local config reloads. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping --- codex-rs/app-server/src/request_processors.rs | 1 + .../src/request_processors/apps_processor.rs | 55 ++++- codex-rs/tui/src/app.rs | 4 - codex-rs/tui/src/app/app_server_events.rs | 10 + codex-rs/tui/src/app/background_requests.rs | 47 +++++ codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 66 +++--- codex-rs/tui/src/app/tests.rs | 2 - codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/chatwidget.rs | 3 - codex-rs/tui/src/chatwidget/connectors.rs | 82 ++------ codex-rs/tui/src/chatwidget/constructor.rs | 2 - codex-rs/tui/src/chatwidget/tests.rs | 5 - codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 - .../tui/src/chatwidget/tests/plan_mode.rs | 1 - .../chatwidget/tests/popups_and_settings.rs | 193 ------------------ .../src/chatwidget/tests/status_and_layout.rs | 1 - codex-rs/tui/src/config_update.rs | 36 ++++ 18 files changed, 189 insertions(+), 326 deletions(-) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 305e864ba0..3e84c397f8 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -275,6 +275,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::connectors::AccessibleConnectorsStatus; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; 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 da2956dbab..caf60a91a0 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -41,11 +41,19 @@ impl AppsRequestProcessor { request_id: &ConnectionRequestId, params: AppsListParams, ) -> Result, JSONRPCErrorError> { - let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; - - if let Some(thread_id) = params.thread_id.as_deref() { - let (_, thread) = self.load_thread(thread_id).await?; + let thread = if let Some(thread_id) = params.thread_id.as_deref() { + let (_, loaded_thread) = self.load_thread(thread_id).await?; + Some(loaded_thread) + } else { + None + }; + let fallback_cwd = match thread.as_ref() { + Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()), + None => None, + }; + let mut config = self.load_latest_config(fallback_cwd).await?; + if let Some(thread) = thread { let _ = config .features .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); @@ -88,8 +96,31 @@ impl AppsRequestProcessor { config: Config, environment_manager: Arc, ) { + let retry_params = params.clone(); + let retry_config = config.clone(); + let retry_environment_manager = Arc::clone(&environment_manager); let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; - outgoing.send_result(request_id, result).await; + let should_retry = result + .as_ref() + .is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready); + outgoing + .send_result(request_id, result.map(|(response, _)| response)) + .await; + + if should_retry && !retry_params.force_refetch { + let mut retry_params = retry_params; + retry_params.force_refetch = true; + if let Err(err) = Self::apps_list_response( + &outgoing, + retry_params, + retry_config, + retry_environment_manager, + ) + .await + { + warn!("failed to refresh app list after codex-apps readiness retry: {err:?}"); + } + } } async fn apps_list_response( @@ -97,7 +128,7 @@ impl AppsRequestProcessor { params: AppsListParams, config: Config, environment_manager: Arc, - ) -> Result { + ) -> Result<(AppsListResponse, bool), JSONRPCErrorError> { let AppsListParams { cursor, limit, @@ -130,7 +161,6 @@ impl AppsRequestProcessor { &environment_manager, ) .await - .map(|status| status.connectors) .map_err(|err| format!("failed to load accessible apps: {err}")); let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); }); @@ -146,6 +176,7 @@ impl AppsRequestProcessor { let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT; let mut accessible_loaded = false; let mut all_loaded = false; + let mut codex_apps_ready = true; let mut last_notified_apps = None; if accessible_connectors.is_some() || all_connectors.is_some() { @@ -178,9 +209,10 @@ impl AppsRequestProcessor { }; match result { - AppListLoadResult::Accessible(Ok(connectors)) => { - accessible_connectors = Some(connectors); + AppListLoadResult::Accessible(Ok(status)) => { + accessible_connectors = Some(status.connectors); accessible_loaded = true; + codex_apps_ready = status.codex_apps_ready; } AppListLoadResult::Accessible(Err(err)) => { return Err(internal_error(err)); @@ -222,7 +254,8 @@ impl AppsRequestProcessor { } if accessible_loaded && all_loaded { - return paginate_apps(merged.as_slice(), start, limit); + let response = paginate_apps(merged.as_slice(), start, limit)?; + return Ok((response, codex_apps_ready)); } } } @@ -279,7 +312,7 @@ impl AppsRequestProcessor { const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); enum AppListLoadResult { - Accessible(Result, String>), + Accessible(Result), Directory(Result, String>), } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7c23fb487a..5cfb520dbe 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -615,7 +615,6 @@ impl App { ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, - environment_manager: self.environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), workspace_command_runner: self.workspace_command_runner.clone(), @@ -788,7 +787,6 @@ impl App { .await; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), - environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), @@ -825,7 +823,6 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), - environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), @@ -867,7 +864,6 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), - environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 413e8b8c80..18e6116e58 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -6,6 +6,7 @@ use super::app_server_event_targets::server_notification_thread_target; use super::app_server_event_targets::server_request_thread_id; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; use codex_app_server_client::AppServerEvent; @@ -106,6 +107,15 @@ impl App { self.fetch_plugins_list(app_server_client, cwd); return; } + ServerNotification::AppListUpdated(notification) => { + self.chat_widget.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: notification.data.clone(), + }), + /*is_final*/ false, + ); + return; + } _ => {} } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index c323a2d5bf..dcf1f672be 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -6,6 +6,9 @@ use super::plugin_mentions::fetch_plugin_mentions; use super::*; +use crate::app_event::ConnectorsSnapshot; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveParams; @@ -92,6 +95,27 @@ impl App { }); } + pub(super) fn fetch_connectors_list( + &mut self, + app_server: &AppServerSession, + force_refetch: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let thread_id = self + .current_displayed_thread_id() + .map(|thread_id| thread_id.to_string()); + tokio::spawn(async move { + let result = fetch_connectors_list(request_handle, force_refetch, thread_id) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ConnectorsLoaded { + result, + is_final: true, + }); + }); + } + pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -634,6 +658,29 @@ pub(super) async fn fetch_skills_list( .wrap_err("skills/list failed in TUI") } +pub(super) async fn fetch_connectors_list( + request_handle: AppServerRequestHandle, + force_refetch: bool, + thread_id: Option, +) -> Result { + let request_id = RequestId::String(format!("apps-list-{}", Uuid::new_v4())); + let response: AppsListResponse = request_handle + .request_typed(ClientRequest::AppsList { + request_id, + params: AppsListParams { + cursor: None, + limit: None, + thread_id, + force_refetch, + }, + }) + .await + .wrap_err("app/list failed in TUI")?; + Ok(ConnectorsSnapshot { + connectors: response.data, + }) +} + pub(super) async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 6f4fda608e..5b2b5ab23a 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -589,6 +589,7 @@ mod tests { use super::*; use crate::app::test_support::app_enabled_in_effective_config; use crate::app::test_support::make_test_app; + use crate::legacy_core::config::edit::ConfigEdit; use crate::test_support::PathBufExt; use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ddb11c4520..987c9490d7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -420,6 +420,9 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::FetchConnectorsList { force_refetch } => { + self.fetch_connectors_list(app_server, force_refetch); + } AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { if refresh_connectors { self.chat_widget.refresh_connectors(/*force_refetch*/ true); @@ -1668,18 +1671,18 @@ impl App { self.chat_widget.open_manage_skills_popup(); } AppEvent::SetSkillEnabled { path, enabled } => { - let edits = [ConfigEdit::SetSkillConfig { - path: path.to_path_buf(), + match crate::config_update::write_skill_enabled( + app_server.request_handle(), + path.clone(), enabled, - }]; - match ConfigEditsBuilder::for_config(&self.config) - .with_edits(edits) - .apply() - .await + ) + .await { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); - if let Err(err) = self.refresh_in_memory_config_from_disk().await { + if !app_server.is_remote() + && let Err(err) = self.refresh_in_memory_config_from_disk().await + { tracing::warn!( error = %err, "failed to refresh config after skill toggle" @@ -1697,44 +1700,35 @@ impl App { AppEvent::SetAppEnabled { id, enabled } => { let edits = if enabled { vec![ - ConfigEdit::ClearPath { - segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], - }, - ConfigEdit::ClearPath { - segments: vec![ - "apps".to_string(), - id.clone(), - "disabled_reason".to_string(), - ], - }, + crate::config_update::clear_config_value( + crate::config_update::app_scoped_key_path(&id, "enabled"), + ), + crate::config_update::clear_config_value( + crate::config_update::app_scoped_key_path(&id, "disabled_reason"), + ), ] } else { vec![ - ConfigEdit::SetPath { - segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], - value: false.into(), - }, - ConfigEdit::SetPath { - segments: vec![ - "apps".to_string(), - id.clone(), - "disabled_reason".to_string(), - ], - value: "user".into(), - }, + crate::config_update::replace_config_value( + crate::config_update::app_scoped_key_path(&id, "enabled"), + serde_json::json!(false), + ), + crate::config_update::replace_config_value( + crate::config_update::app_scoped_key_path(&id, "disabled_reason"), + serde_json::json!("user"), + ), ] }; - match ConfigEditsBuilder::for_config(&self.config) - .with_edits(edits) - .apply() + match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { - Ok(()) => { + Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); - if let Err(err) = self.refresh_in_memory_config_from_disk().await { + if !app_server.is_remote() + && let Err(err) = self.refresh_in_memory_config_from_disk().await + { tracing::warn!(error = %err, "failed to refresh config after app toggle"); } - self.chat_widget.submit_op(AppCommand::reload_user_config()); } Err(err) => { self.chat_widget.add_error_message(format!( diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c42f42c257..547f70890c 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -265,7 +265,6 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { config, - environment_manager: app.environment_manager.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, @@ -4842,7 +4841,6 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { config: app.config.clone(), - environment_manager: app.environment_manager.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 57ec371749..07a5b51175 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -355,6 +355,11 @@ pub(crate) enum AppEvent { force_refetch: bool, }, + /// Fetch app connector state from the app server after the widget accepts a refresh request. + FetchConnectorsList { + force_refetch: bool, + }, + /// Fetch plugin marketplace state for the provided working directory. FetchPluginsList { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d420162914..f58dec02e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -128,7 +128,6 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_config::types::WindowsSandboxModeToml; use codex_core_skills::model::SkillMetadata; -use codex_exec_server::EnvironmentManager; use codex_features::FEATURES; use codex_features::Feature; #[cfg(test)] @@ -464,7 +463,6 @@ const MAX_AGENT_COPY_HISTORY: usize = 32; /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, - pub(crate) environment_manager: Arc, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, /// App-server-backed runner used by status surfaces for workspace metadata probes. @@ -516,7 +514,6 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, transcript: TranscriptState, config: Config, - environment_manager: Arc, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 942f2e9da8..2df64df7d7 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -22,86 +22,36 @@ pub(super) struct ConnectorsState { impl ChatWidget { pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { - self.prefetch_connectors_with_options(force_refetch); + self.queue_connectors_refresh(force_refetch); } pub(super) fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); + self.queue_connectors_refresh(/*force_refetch*/ false); } - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { + fn queue_connectors_refresh(&mut self, force_refetch: bool) { + if self.begin_connectors_refresh(force_refetch) { + self.app_event_tx + .send(AppEvent::FetchConnectorsList { force_refetch }); + } + } + + fn begin_connectors_refresh(&mut self, force_refetch: bool) -> bool { if !self.connectors_enabled() { - return; + return false; } if self.connectors.prefetch_in_flight { if force_refetch { self.connectors.force_refetch_pending = true; } - return; + return false; } self.connectors.prefetch_in_flight = true; if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { self.connectors.cache = ConnectorsCacheState::Loading; } - - let config = self.config.clone(); - let environment_manager = Arc::clone(&self.environment_manager); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let accessible_result = - match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - force_refetch, - &environment_manager, - ) - .await - { - Ok(connectors) => connectors, - Err(err) => { - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Err(format!("Failed to load apps: {err}")), - is_final: true, - }); - return; - } - }; - let should_schedule_force_refetch = - !force_refetch && !accessible_result.codex_apps_ready; - let accessible_connectors = accessible_result.connectors; - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Ok(ConnectorsSnapshot { - connectors: accessible_connectors.clone(), - }), - is_final: false, - }); - - let result: Result = async { - let all_connectors = - chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) - .await?; - let connectors = chatgpt_connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ); - Ok(ConnectorsSnapshot { connectors }) - } - .await - .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result, - is_final: true, - }); - - if should_schedule_force_refetch { - app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); - } - }); + true } pub(super) fn connectors_enabled(&self) -> bool { @@ -135,7 +85,7 @@ impl ChatWidget { let connectors_cache = self.connectors.cache.clone(); let should_force_refetch = !self.connectors.prefetch_in_flight || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); - self.prefetch_connectors_with_options(should_force_refetch); + self.queue_connectors_refresh(should_force_refetch); match connectors_cache { ConnectorsCacheState::Ready(snapshot) => { @@ -360,8 +310,6 @@ impl ChatWidget { /*all_connectors_loaded*/ false, ); } - snapshot.connectors = - chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { let enabled_by_id: HashMap<&str, bool> = existing_snapshot .connectors @@ -404,7 +352,7 @@ impl ChatWidget { } if trigger_pending_force_refetch { - self.prefetch_connectors_with_options(/*force_refetch*/ true); + self.queue_connectors_refresh(/*force_refetch*/ true); } } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index db55be3a88..56bdc6bf68 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -13,7 +13,6 @@ impl ChatWidget { ) -> Self { let ChatWidgetInit { config, - environment_manager, frame_requester, app_event_tx, workspace_command_runner, @@ -104,7 +103,6 @@ impl ChatWidget { transcript: TranscriptState::new(active_cell), raw_output_mode: config.tui_raw_output_mode, config, - environment_manager, effective_service_tier, skills_all: Vec::new(), skills_initial_state: None, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e0dda9eb5c..77aa742c01 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -119,11 +119,7 @@ pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; pub(super) use codex_app_server_protocol::UserInput; pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput; pub(super) use codex_app_server_protocol::WarningNotification; -pub(super) use codex_config::AppRequirementToml; -pub(super) use codex_config::AppsRequirementsToml; pub(super) use codex_config::ConfigLayerStack; -pub(super) use codex_config::ConfigRequirements; -pub(super) use codex_config::ConfigRequirementsToml; pub(super) use codex_config::RequirementSource; pub(super) use codex_config::types::ApprovalsReviewer; pub(super) use codex_config::types::Notifications; @@ -178,7 +174,6 @@ pub(super) use insta::assert_snapshot; pub(super) use serde_json::json; #[cfg(target_os = "windows")] pub(super) use serial_test::serial; -pub(super) use std::collections::BTreeMap; pub(super) use std::collections::HashMap; pub(super) use std::path::PathBuf; pub(super) use tempfile::NamedTempFile; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 28ed69139b..f3abe693aa 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -161,7 +161,6 @@ pub(super) async fn make_chatwidget_manual( let model_catalog = test_model_catalog(&cfg); let common = ChatWidgetInit { config: cfg, - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx, workspace_command_runner: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index b97695e800..091f92c3fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1481,7 +1481,6 @@ async fn make_startup_chat_with_cli_overrides( let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), workspace_command_runner: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 379ff7c6de..2a25415c03 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -72,7 +72,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), workspace_command_runner: None, @@ -1859,198 +1858,6 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { ); } -#[tokio::test] -async fn apps_initial_load_applies_enabled_state_from_config() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); - - let temp = tempdir().expect("tempdir"); - let config_toml_path = temp.path().join("config.toml").abs(); - let user_config = toml::from_str::( - "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", - ) - .expect("apps config"); - chat.config.config_layer_stack = chat - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - - chat.on_connectors_loaded( - Ok(ConnectorsSnapshot { - connectors: vec![AppInfo { - id: "connector_1".to_string(), - name: "Notion".to_string(), - description: Some("Workspace docs".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/notion".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }], - }), - /*is_final*/ true, - ); - - assert_matches!( - &chat.connectors.cache, - ConnectorsCacheState::Ready(snapshot) - if snapshot - .connectors - .iter() - .find(|connector| connector.id == "connector_1") - .is_some_and(|connector| !connector.is_enabled) - ); -} - -#[tokio::test] -async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_override() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); - - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_1".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let temp = tempdir().expect("tempdir"); - let config_toml_path = temp.path().join("config.toml").abs(); - chat.config.config_layer_stack = - ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) - .expect("requirements stack") - .with_user_config( - &config_toml_path, - toml::from_str::( - "[apps.connector_1]\nenabled = true\ndisabled_reason = \"user\"\n", - ) - .expect("apps config"), - ); - - chat.on_connectors_loaded( - Ok(ConnectorsSnapshot { - connectors: vec![AppInfo { - id: "connector_1".to_string(), - name: "Notion".to_string(), - description: Some("Workspace docs".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/notion".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }], - }), - /*is_final*/ true, - ); - - assert_matches!( - &chat.connectors.cache, - ConnectorsCacheState::Ready(snapshot) - if snapshot - .connectors - .iter() - .find(|connector| connector.id == "connector_1") - .is_some_and(|connector| !connector.is_enabled) - ); - - chat.add_connectors_output(); - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Installed · Disabled. Press Enter to open the app page"), - "expected requirements-disabled connector to render as disabled, got:\n{popup}" - ); -} - -#[tokio::test] -async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_entry() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); - - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_1".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - chat.config.config_layer_stack = - ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) - .expect("requirements stack"); - - chat.on_connectors_loaded( - Ok(ConnectorsSnapshot { - connectors: vec![AppInfo { - id: "connector_1".to_string(), - name: "Notion".to_string(), - description: Some("Workspace docs".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/notion".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }], - }), - /*is_final*/ true, - ); - - assert_matches!( - &chat.connectors.cache, - ConnectorsCacheState::Ready(snapshot) - if snapshot - .connectors - .iter() - .find(|connector| connector.id == "connector_1") - .is_some_and(|connector| !connector.is_enabled) - ); - - chat.add_connectors_output(); - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Installed · Disabled. Press Enter to open the app page"), - "expected requirements-disabled connector to render as disabled, got:\n{popup}" - ); -} - #[tokio::test] async fn apps_refresh_preserves_toggled_enabled_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index ac3e236822..799237aa54 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -413,7 +413,6 @@ async fn configured_pet_load_is_deferred_until_after_construction() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, workspace_command_runner: None, diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index bcdbe29a0d..cac79f648e 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use serde_json::Value as JsonValue; @@ -37,6 +40,11 @@ pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> } } +pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { + let app_id = serde_json::Value::String(app_id.to_string()).to_string(); + format!("apps.{app_id}.{key_path}") +} + pub(crate) fn build_model_selection_edits( profile: Option<&str>, model: &str, @@ -109,6 +117,26 @@ pub(crate) async fn write_config_batch( Ok(()) } +pub(crate) async fn write_skill_enabled( + request_handle: AppServerRequestHandle, + path: AbsolutePathBuf, + enabled: bool, +) -> Result<()> { + let request_id = RequestId::String(format!("tui-skill-config-write-{}", Uuid::new_v4())); + let _: SkillsConfigWriteResponse = request_handle + .request_typed(ClientRequest::SkillsConfigWrite { + request_id, + params: SkillsConfigWriteParams { + path: Some(path), + name: None, + enabled, + }, + }) + .await + .wrap_err("skills/config/write failed in TUI")?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -121,4 +149,12 @@ mod tests { "profiles.\"team.prod\".model" ); } + + #[test] + fn app_scoped_key_path_quotes_dotted_app_ids() { + assert_eq!( + app_scoped_key_path("plugin.linear", "enabled"), + "apps.\"plugin.linear\".enabled" + ); + } } From ccbf0137db7ca14ab8c2126518aa430aa30888e6 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 19 May 2026 10:42:54 -0700 Subject: [PATCH 108/114] [codex] Make contextual user fragments dyn-renderable (#23397) ## Why `ContextualUserFragment` needs to be usable behind `dyn` for render-only paths, but associated constants made the trait non-object-safe. ## What changed - Replaced associated constants with trait methods so `dyn ContextualUserFragment` can render fragments. - Preserved the existing typed `T::matches_text(text)` registration pattern via `type_markers()`. - Kept default `render()` on the main trait so implementations only provide role, markers, and body. - Added unit coverage for rendering a `Box`. ## Verification - `cargo test -p codex-core contextual_user_fragment_is_dyn_compatible` - `just fix -p codex-core` --- .../context/approved_command_prefix_saved.rs | 14 +++-- .../core/src/context/apps_instructions.rs | 14 +++-- .../context/available_plugins_instructions.rs | 17 ++++-- .../context/available_skills_instructions.rs | 14 +++-- .../collaboration_mode_instructions.rs | 14 +++-- .../context/contextual_user_message_tests.rs | 15 ++++++ .../core/src/context/environment_context.rs | 17 ++++-- codex-rs/core/src/context/fragment.rs | 53 ++++++++++++------- codex-rs/core/src/context/goal_context.rs | 14 +++-- .../guardian_followup_review_reminder.rs | 14 +++-- .../src/context/hook_additional_context.rs | 14 +++-- .../context/image_generation_instructions.rs | 14 +++-- ...legacy_apply_patch_exec_command_warning.rs | 14 +++-- .../context/legacy_model_mismatch_warning.rs | 14 +++-- ...gacy_unified_exec_process_limit_warning.rs | 14 +++-- .../src/context/model_switch_instructions.rs | 14 +++-- .../core/src/context/network_rule_saved.rs | 14 +++-- .../src/context/permissions_instructions.rs | 14 +++-- .../context/personality_spec_instructions.rs | 14 +++-- .../core/src/context/plugin_instructions.rs | 14 +++-- .../src/context/realtime_end_instructions.rs | 17 ++++-- .../context/realtime_start_instructions.rs | 17 ++++-- .../realtime_start_with_instructions.rs | 17 ++++-- .../core/src/context/skill_instructions.rs | 14 +++-- .../core/src/context/subagent_notification.rs | 14 +++-- codex-rs/core/src/context/turn_aborted.rs | 14 +++-- .../core/src/context/user_instructions.rs | 14 +++-- .../core/src/context/user_shell_command.rs | 14 +++-- codex-rs/core/src/goals.rs | 2 +- 29 files changed, 350 insertions(+), 99 deletions(-) diff --git a/codex-rs/core/src/context/approved_command_prefix_saved.rs b/codex-rs/core/src/context/approved_command_prefix_saved.rs index 3aac17712a..532a2494c3 100644 --- a/codex-rs/core/src/context/approved_command_prefix_saved.rs +++ b/codex-rs/core/src/context/approved_command_prefix_saved.rs @@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved { } impl ContextualUserFragment for ApprovedCommandPrefixSaved { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!("Approved command prefix saved:\n{}", self.prefixes) diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 55865b429a..9a475dc311 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -18,9 +18,17 @@ impl AppsInstructions { } impl ContextualUserFragment for AppsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG) + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index 52f9c8df48..e7f560f6a1 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -22,9 +22,20 @@ impl AvailablePluginsInstructions { } impl ContextualUserFragment for AvailablePluginsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + PLUGINS_INSTRUCTIONS_OPEN_TAG, + PLUGINS_INSTRUCTIONS_CLOSE_TAG, + ) + } fn body(&self) -> String { let mut lines = vec![ diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index 0a99bf62e6..e63d637677 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -21,9 +21,17 @@ impl From for AvailableSkillsInstructions { } impl ContextualUserFragment for AvailableSkillsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG) + } fn body(&self) -> String { render_available_skills_body(&self.skill_root_lines, &self.skill_lines) diff --git a/codex-rs/core/src/context/collaboration_mode_instructions.rs b/codex-rs/core/src/context/collaboration_mode_instructions.rs index dcf3bac1de..16e3d38198 100644 --- a/codex-rs/core/src/context/collaboration_mode_instructions.rs +++ b/codex-rs/core/src/context/collaboration_mode_instructions.rs @@ -22,9 +22,17 @@ impl CollaborationModeInstructions { } impl ContextualUserFragment for CollaborationModeInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG; - const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG) + } fn body(&self) -> String { self.instructions.clone() diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index ee134cc685..7e04854d42 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -1,8 +1,11 @@ use super::*; use crate::context::ContextualUserFragment; +use crate::context::GoalContext; +use crate::context::SubagentNotification; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; #[test] fn detects_environment_context_fragment() { @@ -38,6 +41,18 @@ fn detects_goal_context_fragment() { })); } +#[test] +fn contextual_user_fragment_is_dyn_compatible() { + let fragment: Box = Box::new(GoalContext { + prompt: "Continue working toward the active thread goal.".to_string(), + }); + + assert_eq!( + fragment.render(), + "\nContinue working toward the active thread goal.\n" + ); +} + #[test] fn ignores_regular_user_text() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 272e3c617d..a3052b6642 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -269,9 +269,20 @@ impl EnvironmentContext { } impl ContextualUserFragment for EnvironmentContext { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; - const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, + codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, + ) + } fn body(&self) -> String { let mut lines = Vec::new(); diff --git a/codex-rs/core/src/context/fragment.rs b/codex-rs/core/src/context/fragment.rs index 1cc8f6d9b8..4a9e284d12 100644 --- a/codex-rs/core/src/context/fragment.rs +++ b/codex-rs/core/src/context/fragment.rs @@ -38,37 +38,34 @@ impl FragmentRegistration for FragmentRegistrationPro /// in which case the default helpers render only the body and never match /// arbitrary text. pub trait ContextualUserFragment { - const ROLE: &'static str; - const START_MARKER: &'static str; - const END_MARKER: &'static str; + fn role() -> &'static str + where + Self: Sized; + + fn markers(&self) -> (&'static str, &'static str); fn body(&self) -> String; + fn type_markers() -> (&'static str, &'static str) + where + Self: Sized; + fn matches_text(text: &str) -> bool where Self: Sized, { - if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() { - return false; - } - - let trimmed = text.trim_start(); - let starts_with_marker = trimmed - .get(..Self::START_MARKER.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::START_MARKER)); - let trimmed = trimmed.trim_end(); - let ends_with_marker = trimmed - .get(trimmed.len().saturating_sub(Self::END_MARKER.len())..) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::END_MARKER)); - starts_with_marker && ends_with_marker + let (start_marker, end_marker) = Self::type_markers(); + matches_marked_text(start_marker, end_marker, text) } fn render(&self) -> String { - if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() { - return self.body(); + let (start_marker, end_marker) = self.markers(); + let body = self.body(); + if start_marker.is_empty() && end_marker.is_empty() { + return body; } - format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER) + format!("{start_marker}{body}{end_marker}") } fn into(self) -> ResponseItem @@ -77,7 +74,7 @@ pub trait ContextualUserFragment { { ResponseItem::Message { id: None, - role: Self::ROLE.to_string(), + role: Self::role().to_string(), content: vec![ContentItem::InputText { text: self.render(), }], @@ -85,3 +82,19 @@ pub trait ContextualUserFragment { } } } + +fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { + if start_marker.is_empty() || end_marker.is_empty() { + return false; + } + + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker)); + starts_with_marker && ends_with_marker +} diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs index d259273324..240d4d0e8a 100644 --- a/codex-rs/core/src/context/goal_context.rs +++ b/codex-rs/core/src/context/goal_context.rs @@ -8,9 +8,17 @@ pub(crate) struct GoalContext { } impl ContextualUserFragment for GoalContext { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!("\n{}\n", self.prompt) diff --git a/codex-rs/core/src/context/guardian_followup_review_reminder.rs b/codex-rs/core/src/context/guardian_followup_review_reminder.rs index 972355ba95..4de750629c 100644 --- a/codex-rs/core/src/context/guardian_followup_review_reminder.rs +++ b/codex-rs/core/src/context/guardian_followup_review_reminder.rs @@ -4,9 +4,17 @@ use super::ContextualUserFragment; pub(crate) struct GuardianFollowupReviewReminder; impl ContextualUserFragment for GuardianFollowupReviewReminder { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { concat!( diff --git a/codex-rs/core/src/context/hook_additional_context.rs b/codex-rs/core/src/context/hook_additional_context.rs index 01c95a6524..1094261665 100644 --- a/codex-rs/core/src/context/hook_additional_context.rs +++ b/codex-rs/core/src/context/hook_additional_context.rs @@ -12,9 +12,17 @@ impl HookAdditionalContext { } impl ContextualUserFragment for HookAdditionalContext { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { self.text.clone() diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 01abee9096..7ddb231b8f 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -17,9 +17,17 @@ impl ImageGenerationInstructions { } impl ContextualUserFragment for ImageGenerationInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs index d8fd7da8e9..b9ecbf6170 100644 --- a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs +++ b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyApplyPatchExecCommandWarning; impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { let trimmed = text.trim(); diff --git a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs index a6afa9d7d1..9b769e11e9 100644 --- a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs +++ b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyModelMismatchWarning; impl ContextualUserFragment for LegacyModelMismatchWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { text.trim().starts_with( diff --git a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs index 463aeaa8aa..54f1a40e3e 100644 --- a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs +++ b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyUnifiedExecProcessLimitWarning; impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { text.trim().starts_with( diff --git a/codex-rs/core/src/context/model_switch_instructions.rs b/codex-rs/core/src/context/model_switch_instructions.rs index 0b75afb61a..f15dcd22e0 100644 --- a/codex-rs/core/src/context/model_switch_instructions.rs +++ b/codex-rs/core/src/context/model_switch_instructions.rs @@ -14,9 +14,17 @@ impl ModelSwitchInstructions { } impl ContextualUserFragment for ModelSwitchInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/network_rule_saved.rs b/codex-rs/core/src/context/network_rule_saved.rs index 8162e6bda2..5b45dbb560 100644 --- a/codex-rs/core/src/context/network_rule_saved.rs +++ b/codex-rs/core/src/context/network_rule_saved.rs @@ -18,9 +18,17 @@ impl NetworkRuleSaved { } impl ContextualUserFragment for NetworkRuleSaved { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { let (action, list_name) = match self.action { diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index e982ca17d2..db8f983c16 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -145,9 +145,17 @@ fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAc } impl ContextualUserFragment for PermissionsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { self.text.clone() diff --git a/codex-rs/core/src/context/personality_spec_instructions.rs b/codex-rs/core/src/context/personality_spec_instructions.rs index 17c9cf595b..590ebde80d 100644 --- a/codex-rs/core/src/context/personality_spec_instructions.rs +++ b/codex-rs/core/src/context/personality_spec_instructions.rs @@ -12,9 +12,17 @@ impl PersonalitySpecInstructions { } impl ContextualUserFragment for PersonalitySpecInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/plugin_instructions.rs b/codex-rs/core/src/context/plugin_instructions.rs index 0678033bbe..8fbccbfa10 100644 --- a/codex-rs/core/src/context/plugin_instructions.rs +++ b/codex-rs/core/src/context/plugin_instructions.rs @@ -12,9 +12,17 @@ impl PluginInstructions { } impl ContextualUserFragment for PluginInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { self.text.clone() diff --git a/codex-rs/core/src/context/realtime_end_instructions.rs b/codex-rs/core/src/context/realtime_end_instructions.rs index 0edd852895..a8c6226f2a 100644 --- a/codex-rs/core/src/context/realtime_end_instructions.rs +++ b/codex-rs/core/src/context/realtime_end_instructions.rs @@ -18,9 +18,20 @@ impl RealtimeEndInstructions { } impl ContextualUserFragment for RealtimeEndInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG; - const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + REALTIME_CONVERSATION_OPEN_TAG, + REALTIME_CONVERSATION_CLOSE_TAG, + ) + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/realtime_start_instructions.rs b/codex-rs/core/src/context/realtime_start_instructions.rs index 6745a74fd3..cc767c202a 100644 --- a/codex-rs/core/src/context/realtime_start_instructions.rs +++ b/codex-rs/core/src/context/realtime_start_instructions.rs @@ -8,9 +8,20 @@ const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtim pub(crate) struct RealtimeStartInstructions; impl ContextualUserFragment for RealtimeStartInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG; - const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + REALTIME_CONVERSATION_OPEN_TAG, + REALTIME_CONVERSATION_CLOSE_TAG, + ) + } fn body(&self) -> String { format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim()) diff --git a/codex-rs/core/src/context/realtime_start_with_instructions.rs b/codex-rs/core/src/context/realtime_start_with_instructions.rs index ddaf4528df..ee242f9068 100644 --- a/codex-rs/core/src/context/realtime_start_with_instructions.rs +++ b/codex-rs/core/src/context/realtime_start_with_instructions.rs @@ -16,9 +16,20 @@ impl RealtimeStartWithInstructions { } impl ContextualUserFragment for RealtimeStartWithInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG; - const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + REALTIME_CONVERSATION_OPEN_TAG, + REALTIME_CONVERSATION_CLOSE_TAG, + ) + } fn body(&self) -> String { format!("\n{}\n", self.instructions) diff --git a/codex-rs/core/src/context/skill_instructions.rs b/codex-rs/core/src/context/skill_instructions.rs index 22f6ba2255..5c4b4b78a6 100644 --- a/codex-rs/core/src/context/skill_instructions.rs +++ b/codex-rs/core/src/context/skill_instructions.rs @@ -20,9 +20,17 @@ impl From<&SkillInjection> for SkillInstructions { } impl ContextualUserFragment for SkillInstructions { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/subagent_notification.rs b/codex-rs/core/src/context/subagent_notification.rs index bdb389f366..0c1358f33c 100644 --- a/codex-rs/core/src/context/subagent_notification.rs +++ b/codex-rs/core/src/context/subagent_notification.rs @@ -18,9 +18,17 @@ impl SubagentNotification { } impl ContextualUserFragment for SubagentNotification { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/turn_aborted.rs b/codex-rs/core/src/context/turn_aborted.rs index 34c02b9cf6..6dc6697288 100644 --- a/codex-rs/core/src/context/turn_aborted.rs +++ b/codex-rs/core/src/context/turn_aborted.rs @@ -17,9 +17,17 @@ impl TurnAborted { } impl ContextualUserFragment for TurnAborted { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!("\n{}\n", self.guidance) diff --git a/codex-rs/core/src/context/user_instructions.rs b/codex-rs/core/src/context/user_instructions.rs index dead5971da..1b3fa40298 100644 --- a/codex-rs/core/src/context/user_instructions.rs +++ b/codex-rs/core/src/context/user_instructions.rs @@ -7,9 +7,17 @@ pub(crate) struct UserInstructions { } impl ContextualUserFragment for UserInstructions { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = "# AGENTS.md instructions for "; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("# AGENTS.md instructions for ", "") + } fn body(&self) -> String { format!("{}\n\n\n{}\n", self.directory, self.text) diff --git a/codex-rs/core/src/context/user_shell_command.rs b/codex-rs/core/src/context/user_shell_command.rs index 2350687abc..cf5a3337d0 100644 --- a/codex-rs/core/src/context/user_shell_command.rs +++ b/codex-rs/core/src/context/user_shell_command.rs @@ -27,9 +27,17 @@ impl UserShellCommand { } impl ContextualUserFragment for UserShellCommand { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 73e82fdcfd..6843264e44 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1634,7 +1634,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { fn goal_context_input_item(prompt: String) -> ResponseInputItem { let context = GoalContext { prompt }; ResponseInputItem::Message { - role: ::ROLE.to_string(), + role: GoalContext::role().to_string(), content: vec![ContentItem::InputText { text: context.render(), }], From 05b8ce43541bbcd3ed14a21ea3f103c20cb9392b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 19 May 2026 19:46:17 +0200 Subject: [PATCH 109/114] chore: namespace v1 sub-agent tools (#23475) ## Why The v1 sub-agent tools are a single tool family, but they were exposed as separate flat function tools. This makes the model-visible surface less clearly grouped and leaves the legacy names in the same flat namespace as newer agent tooling. ## What - Wraps the v1 `spawn_agent`, `send_input`, `resume_agent`, `wait_agent`, and `close_agent` specs in the `multi_agent_v1` namespace. - Registers the corresponding handlers with namespaced runtime tool names. - Updates tool-planning, deferred tool search, and sub-agent notification tests to assert the namespace shape and child `spawn_agent` lookup. ## Verification - Updated `codex-core` coverage for the v1 multi-agent tool plan, deferred tool search output, and sub-agent tool descriptions. --- .../app-server/tests/suite/v2/turn_start.rs | 14 +- .../core/src/tools/handlers/multi_agents.rs | 1 + .../handlers/multi_agents/close_agent.rs | 2 +- .../handlers/multi_agents/resume_agent.rs | 2 +- .../tools/handlers/multi_agents/send_input.rs | 2 +- .../src/tools/handlers/multi_agents/spawn.rs | 2 +- .../src/tools/handlers/multi_agents/wait.rs | 2 +- .../src/tools/handlers/multi_agents_spec.rs | 113 ++++++++------ .../tools/handlers/multi_agents_spec_tests.rs | 12 +- codex-rs/core/src/tools/spec_plan_tests.rs | 33 +++- codex-rs/core/tests/suite/approvals.rs | 8 +- .../suite/responses_api_proxy_headers.rs | 9 +- codex-rs/core/tests/suite/search_tool.rs | 22 ++- .../tests/suite/spawn_agent_description.rs | 19 +-- .../tests/suite/subagent_notifications.rs | 144 ++++++++---------- 15 files changed, 226 insertions(+), 159 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 156c0b2081..66f704f83d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2910,7 +2910,12 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-turn1-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-turn1-1"), ]), ) @@ -3107,7 +3112,12 @@ async fn turn_start_emits_spawn_agent_item_with_effective_role_model_metadata_v2 |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-turn1-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-turn1-1"), ]), ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index d26b64722d..00176572e0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -15,6 +15,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; pub(crate) use crate::tools::handlers::multi_agents_common::*; +use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index ce8bd5d2ab..bcbde4a6d3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -8,7 +8,7 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("close_agent") + ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent") } fn spec(&self) -> Option { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index fcb8aeab18..ddfe6ee6a7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -10,7 +10,7 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("resume_agent") + ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent") } fn spec(&self) -> Option { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 3a42f425ec..3322e1b3d4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -9,7 +9,7 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("send_input") + ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input") } fn spec(&self) -> Option { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index ce87c9f08d..ceb46083c5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -25,7 +25,7 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("spawn_agent") + ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent") } fn spec(&self) -> Option { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 97841b3961..68f3dd7ee4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -30,7 +30,7 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { fn tool_name(&self) -> ToolName { - ToolName::plain("wait_agent") + ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent") } fn spec(&self) -> Option { 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 e1295b50de..8aad428411 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -1,11 +1,16 @@ use codex_protocol::openai_models::ModelPreset; use codex_tools::JsonSchema; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; +pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; +const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; + const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."; const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; @@ -48,18 +53,22 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { hide_spawn_agent_metadata_options(&mut properties); } - ToolSpec::Function(ResponsesApiTool { - name: "spawn_agent".to_string(), - description: spawn_agent_tool_description( - available_models_description.as_deref(), - return_value_description, - options.include_usage_hint, - options.usage_hint_text, - ), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())), - output_schema: Some(spawn_agent_output_schema_v1()), + ToolSpec::Namespace(ResponsesApiNamespace { + name: MULTI_AGENT_V1_NAMESPACE.to_string(), + description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "spawn_agent".to_string(), + description: spawn_agent_tool_description( + available_models_description.as_deref(), + return_value_description, + options.include_usage_hint, + options.usage_hint_text, + ), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())), + output_schema: Some(spawn_agent_output_schema_v1()), + })], }) } @@ -122,14 +131,18 @@ pub fn create_send_input_tool_v1() -> ToolSpec { ), ]); - ToolSpec::Function(ResponsesApiTool { - name: "send_input".to_string(), - description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), - output_schema: Some(send_input_output_schema()), + ToolSpec::Namespace(ResponsesApiNamespace { + name: MULTI_AGENT_V1_NAMESPACE.to_string(), + description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "send_input".to_string(), + description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), + output_schema: Some(send_input_output_schema()), + })], }) } @@ -197,27 +210,35 @@ pub fn create_resume_agent_tool() -> ToolSpec { JsonSchema::string(Some("Agent id to resume.".to_string())), )]); - ToolSpec::Function(ResponsesApiTool { - name: "resume_agent".to_string(), - description: - "Resume a previously closed agent by id so it can receive send_input and wait_agent calls." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())), - output_schema: Some(resume_agent_output_schema()), + ToolSpec::Namespace(ResponsesApiNamespace { + name: MULTI_AGENT_V1_NAMESPACE.to_string(), + description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "resume_agent".to_string(), + description: + "Resume a previously closed agent by id so it can receive send_input and wait_agent calls." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())), + output_schema: Some(resume_agent_output_schema()), + })], }) } pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: "wait_agent".to_string(), - description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." - .to_string(), - strict: false, - defer_loading: None, - parameters: wait_agent_tool_parameters_v1(options), - output_schema: Some(wait_output_schema_v1()), + ToolSpec::Namespace(ResponsesApiNamespace { + name: MULTI_AGENT_V1_NAMESPACE.to_string(), + description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "wait_agent".to_string(), + description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." + .to_string(), + strict: false, + defer_loading: None, + parameters: wait_agent_tool_parameters_v1(options), + output_schema: Some(wait_output_schema_v1()), + })], }) } @@ -260,13 +281,17 @@ pub fn create_close_agent_tool_v1() -> ToolSpec { JsonSchema::string(Some("Agent id to close (from spawn_agent).".to_string())), )]); - ToolSpec::Function(ResponsesApiTool { - name: "close_agent".to_string(), - description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), - output_schema: Some(close_agent_output_schema()), + ToolSpec::Namespace(ResponsesApiNamespace { + name: MULTI_AGENT_V1_NAMESPACE.to_string(), + description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "close_agent".to_string(), + description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())), + output_schema: Some(close_agent_output_schema()), + })], }) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 711db2fd11..6f9eac1fff 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -120,11 +120,17 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { max_concurrent_threads_per_session: None, }); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { - panic!("spawn_agent should be a function tool"); + let ToolSpec::Namespace(namespace) = tool else { + panic!("spawn_agent v1 should be a namespace tool"); + }; + assert_eq!(namespace.name, MULTI_AGENT_V1_NAMESPACE); + let Some(ResponsesApiNamespaceTool::Function(ResponsesApiTool { parameters, .. })) = + namespace.tools.first() + else { + panic!("spawn_agent should be a namespace function tool"); }; assert_eq!( - parameters.schema_type, + parameters.schema_type.clone(), Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) ); let properties = parameters diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 57bab6e39f..22276af550 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -27,6 +27,7 @@ use serde_json::json; use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnContext; +use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::router::ToolRouter; use crate::tools::router::ToolRouterParams; @@ -602,14 +603,27 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false); }) .await; - v1.assert_visible_contains(&[ + v1.assert_visible_contains(&[MULTI_AGENT_V1_NAMESPACE]); + v1.assert_visible_lacks(&[ "spawn_agent", "send_input", "resume_agent", "wait_agent", "close_agent", + "send_message", + "followup_task", + "list_agents", ]); - v1.assert_visible_lacks(&["send_message", "followup_task", "list_agents"]); + assert_eq!( + v1.namespace_function_names(MULTI_AGENT_V1_NAMESPACE), + &[ + "close_agent".to_string(), + "resume_agent".to_string(), + "send_input".to_string(), + "spawn_agent".to_string(), + "wait_agent".to_string(), + ] + ); let v2 = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); @@ -678,8 +692,19 @@ async fn v1_multi_agent_tools_defer_when_tool_search_available() { "wait_agent", "close_agent", ] { - plan.assert_registered_contains(&[tool_name]); - assert_eq!(plan.exposure(tool_name), ToolExposure::Deferred); + let namespaced_tool_name = ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, tool_name); + let namespaced_tool_name = namespaced_tool_name.to_string(); + assert!( + plan.registered_names.contains(&namespaced_tool_name), + "expected namespaced runtime for {tool_name}" + ); + assert!( + !plan + .registered_names + .contains(&ToolName::plain(tool_name).to_string()), + "expected no plain runtime for deferred {tool_name}" + ); + assert_eq!(plan.exposure(&namespaced_tool_name), ToolExposure::Deferred); } let ToolSpec::ToolSearch { description, .. } = plan.visible_spec("tool_search") else { panic!("expected visible tool_search spec"); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f08262eb6f..924253d8b8 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -2373,7 +2374,12 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> |req: &Request| body_contains(req, PARENT_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) 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 f396a825a1..c18b431984 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -13,7 +13,7 @@ use core_test_support::responses::ResponseMock; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; @@ -46,7 +46,12 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - }, sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index ca5c146e5a..706ff4871e 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -745,13 +745,25 @@ async fn tool_search_returns_deferred_v1_multi_agent_tools() -> Result<()> { ); let tools = tool_search_output_tools(&requests[1], call_id); - let spawn_agent = tools - .iter() - .find(|tool| { + assert!( + !tools.iter().any(|tool| { tool.get("type").and_then(Value::as_str) == Some("function") && tool.get("name").and_then(Value::as_str) == Some("spawn_agent") - }) - .unwrap_or_else(|| panic!("expected tool_search to return spawn_agent: {tools:?}")); + }), + "spawn_agent should be returned as a namespace child, not a flat function: {tools:?}" + ); + assert!( + tools.iter().any(|tool| { + tool.get("type").and_then(Value::as_str) == Some("namespace") + && tool.get("name").and_then(Value::as_str) == Some("multi_agent_v1") + }), + "expected tool_search to return multi_agent_v1 namespace: {tools:?}" + ); + let output = tool_search_output_item(&requests[1], call_id); + let spawn_agent = namespace_child_tool(&output, "multi_agent_v1", "spawn_agent") + .unwrap_or_else(|| { + panic!("expected tool_search to return multi_agent_v1.spawn_agent: {output:?}") + }); assert_eq!( spawn_agent.get("defer_loading").and_then(Value::as_bool), Some(true) diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index c70f9bce37..9a7d70adeb 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -20,6 +20,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; @@ -28,22 +29,14 @@ use std::time::Duration; use std::time::Instant; use tokio::time::sleep; +const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const SPAWN_AGENT_TOOL_NAME: &str = "spawn_agent"; fn spawn_agent_description(body: &Value) -> Option { - body.get("tools") - .and_then(Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(SPAWN_AGENT_TOOL_NAME) { - tool.get("description") - .and_then(Value::as_str) - .map(str::to_string) - } else { - None - } - }) - }) + namespace_child_tool(body, MULTI_AGENT_V1_NAMESPACE, SPAWN_AGENT_TOOL_NAME) + .and_then(|tool| tool.get("description")) + .and_then(Value::as_str) + .map(str::to_string) } fn test_model_info( diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index f56df240d5..b648cb3d44 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -7,12 +7,13 @@ use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_response_once_match; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; @@ -30,6 +31,7 @@ use tokio::time::sleep; use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; +const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; @@ -76,15 +78,6 @@ fn tool_parameter_description(tool: &Value, parameter_name: &str) -> Option Vec { - request - .tool_search_output(call_id) - .get("tools") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() -} - fn role_block(description: &str, role_name: &str) -> Option { let role_header = format!("{role_name}: {{"); let mut lines = description.lines().skip_while(|line| *line != role_header); @@ -144,7 +137,7 @@ async fn setup_turn_one_with_spawned_child( server: &MockServer, child_response_delay: Option, ) -> Result<(TestCodex, String)> { - setup_turn_one_with_custom_spawned_child( + let (test, spawned_id, _child_request_log) = setup_turn_one_with_custom_spawned_child( server, json!({ "message": CHILD_PROMPT, @@ -153,7 +146,8 @@ async fn setup_turn_one_with_spawned_child( /*wait_for_parent_notification*/ true, |builder| builder, ) - .await + .await?; + Ok((test, spawned_id)) } async fn setup_turn_one_with_custom_spawned_child( @@ -164,7 +158,11 @@ async fn setup_turn_one_with_custom_spawned_child( configure_test: impl FnOnce( core_test_support::test_codex::TestCodexBuilder, ) -> core_test_support::test_codex::TestCodexBuilder, -) -> Result<(TestCodex, String)> { +) -> Result<( + TestCodex, + String, + core_test_support::responses::ResponseMock, +)> { let spawn_args = serde_json::to_string(&spawn_args)?; mount_sse_once_match( @@ -172,7 +170,12 @@ async fn setup_turn_one_with_custom_spawned_child( |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-turn1-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V1_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-turn1-1"), ]), ) @@ -249,7 +252,7 @@ async fn setup_turn_one_with_custom_spawned_child( } let spawned_id = wait_for_spawned_thread_id(&test).await?; - Ok((test, spawned_id)) + Ok((test, spawned_id, child_request_log)) } async fn spawn_child_and_capture_snapshot( @@ -259,7 +262,7 @@ async fn spawn_child_and_capture_snapshot( core_test_support::test_codex::TestCodexBuilder, ) -> core_test_support::test_codex::TestCodexBuilder, ) -> Result { - let (test, spawned_id) = setup_turn_one_with_custom_spawned_child( + let (test, spawned_id, _child_request_log) = setup_turn_one_with_custom_spawned_child( server, spawn_args, /*child_response_delay*/ None, @@ -328,7 +331,12 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> { |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-turn1-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V1_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-turn1-1"), ]), ) @@ -434,15 +442,22 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-turn1-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V1_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-turn1-1"), ]), ) .await; - let _child_request_log = mount_sse_once_match( + let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, CHILD_PROMPT), + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, sse(vec![ ev_response_created("resp-child-1"), ev_completed("resp-child-1"), @@ -452,9 +467,7 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res let _turn1_followup = mount_sse_once_match( &server, - |req: &wiremock::Request| { - body_contains(req, "function_call_output") && body_contains(req, "/root/worker") - }, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), sse(vec![ ev_response_created("resp-turn1-2"), ev_assistant_message("msg-turn1-2", "parent done"), @@ -478,29 +491,12 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res test.submit_turn(TURN_1_PROMPT).await?; - let deadline = Instant::now() + Duration::from_secs(2); - let child_request = loop { - if let Some(request) = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find(|request| { - body_contains(request, CHILD_PROMPT) && !body_contains(request, SPAWN_CALL_ID) - }) - { - break request; - } - if Instant::now() >= deadline { - anyhow::bail!("timed out waiting for spawned child request with developer context"); - } - sleep(Duration::from_millis(10)).await; - }; - assert!(body_contains( - &child_request, - "Parent developer instructions." - )); - assert!(body_contains(&child_request, CHILD_PROMPT)); + let child_requests = wait_for_requests(&child_request_log).await?; + let child_request = child_requests + .last() + .expect("child request log should capture at least one request"); + assert!(child_request.body_contains_text("Parent developer instructions.")); + assert!(child_request.body_contains_text(CHILD_PROMPT)); Ok(()) } @@ -519,15 +515,22 @@ async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Resu |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-turn1-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V1_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-turn1-1"), ]), ) .await; - let _child_request_log = mount_sse_once_match( + let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, CHILD_PROMPT), + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, sse(vec![ ev_response_created("resp-child-1"), ev_completed("resp-child-1"), @@ -537,9 +540,7 @@ async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Resu let _turn1_followup = mount_sse_once_match( &server, - |req: &wiremock::Request| { - body_contains(req, "function_call_output") && body_contains(req, "/root/worker") - }, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), sse(vec![ ev_response_created("resp-turn1-2"), ev_assistant_message("msg-turn1-2", "parent done"), @@ -572,26 +573,12 @@ async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Resu assert!(!parent_request.body_contains_text("")); assert!(!parent_request.body_contains_text("demo-skill")); - let deadline = Instant::now() + Duration::from_secs(2); - let child_request = loop { - if let Some(request) = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find(|request| { - body_contains(request, CHILD_PROMPT) && !body_contains(request, SPAWN_CALL_ID) - }) - { - break request; - } - if Instant::now() >= deadline { - anyhow::bail!("timed out waiting for spawned child request"); - } - sleep(Duration::from_millis(10)).await; - }; - assert!(!body_contains(&child_request, "")); - assert!(!body_contains(&child_request, "demo-skill")); + let child_requests = wait_for_requests(&child_request_log).await?; + let child_request = child_requests + .last() + .expect("child request log should capture at least one request"); + assert!(!child_request.body_contains_text("")); + assert!(!child_request.body_contains_text("demo-skill")); Ok(()) } @@ -695,14 +682,11 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< let requests = resp_mock.requests(); assert_eq!(requests.len(), 2); - let tools = tool_search_output_tools(&requests[1], call_id); - let spawn_agent = tools - .iter() - .find(|tool| { - tool.get("type").and_then(Value::as_str) == Some("function") - && tool.get("name").and_then(Value::as_str) == Some("spawn_agent") - }) - .unwrap_or_else(|| panic!("expected tool_search to return spawn_agent: {tools:?}")); + let output = requests[1].tool_search_output(call_id); + let spawn_agent = namespace_child_tool(&output, "multi_agent_v1", "spawn_agent") + .unwrap_or_else(|| { + panic!("expected tool_search to return multi_agent_v1.spawn_agent: {output:?}") + }); let agent_type_description = tool_parameter_description(spawn_agent, "agent_type") .expect("spawn_agent agent_type description"); let custom_role_description = From 3c760818767ed2e2271673e2ff7771039e873fe7 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 19 May 2026 11:03:47 -0700 Subject: [PATCH 110/114] Make `deny` canonical for filesystem permission entries (#23493) ## Why Filesystem permission profiles used `none` for deny-read entries, which is less direct than the action the entry actually represents. This change makes `deny` the canonical filesystem permission spelling while preserving compatibility for older configs that still send `none`. ## What changed - rename `FileSystemAccessMode::None` to `Deny` - serialize and generate schemas with `deny` as the canonical value - retain `none` only as a legacy input alias for temporary config compatibility - update filesystem glob diagnostics and regression coverage to use the canonical spelling - refresh config and app-server schema fixtures to match the new wire shape ## Validation - `cargo test -p codex-protocol` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core config_toml_deserializes_permission_profiles --lib` - `cargo test -p codex-core read_write_glob_patterns_still_reject_non_subpath_globs --lib` Earlier in the session, a broad `cargo test -p codex-core` run reached unrelated pre-existing failures in timing/snapshot/git-info tests under this environment; the targeted surfaces touched by this PR passed cleanly. --- ...CommandExecutionRequestApprovalParams.json | 2 +- .../PermissionsRequestApprovalParams.json | 2 +- .../PermissionsRequestApprovalResponse.json | 2 +- .../schema/json/ServerNotification.json | 2 +- .../schema/json/ServerRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- ...anApprovalReviewCompletedNotification.json | 2 +- ...dianApprovalReviewStartedNotification.json | 2 +- .../typescript/v2/FileSystemAccessMode.ts | 2 +- .../src/protocol/v2/permissions.rs | 2 +- .../src/protocol/v2/tests.rs | 4 +- .../thread_processor_tests.rs | 2 +- codex-rs/core/config.schema.json | 24 +++++-- codex-rs/core/src/config/config_tests.rs | 9 ++- codex-rs/core/src/config/mod.rs | 4 +- codex-rs/core/src/config/permissions.rs | 20 +++--- codex-rs/core/src/config/permissions_tests.rs | 10 +-- codex-rs/core/src/exec_tests.rs | 6 +- codex-rs/core/src/safety_tests.rs | 2 +- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/src/tools/handlers/mod.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 2 +- .../runtimes/shell/unix_escalation_tests.rs | 2 +- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 2 +- codex-rs/core/tests/suite/tools.rs | 2 +- codex-rs/core/tests/suite/unified_exec.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 2 +- codex-rs/core/tests/suite/windows_sandbox.rs | 8 +-- codex-rs/linux-sandbox/src/bwrap.rs | 20 +++--- .../linux-sandbox/tests/suite/landlock.rs | 6 +- codex-rs/protocol/src/models.rs | 4 +- codex-rs/protocol/src/permissions.rs | 72 ++++++++++--------- codex-rs/protocol/src/protocol.rs | 8 +-- codex-rs/sandboxing/src/manager_tests.rs | 4 +- codex-rs/sandboxing/src/policy_transforms.rs | 8 +-- .../sandboxing/src/policy_transforms_tests.rs | 38 +++++----- codex-rs/sandboxing/src/seatbelt_tests.rs | 6 +- codex-rs/tui/src/app/thread_session_state.rs | 2 +- .../tui/src/bottom_pane/approval_overlay.rs | 4 +- .../chatwidget/tests/composer_submission.rs | 2 +- .../src/chatwidget/tests/history_replay.rs | 2 +- .../src/deny_read_resolver.rs | 6 +- 44 files changed, 165 insertions(+), 148 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 5b6c4cd185..922db80f2f 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -277,7 +277,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 1383da6124..337a469890 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 3e775a3da9..5cce2cdc5a 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5d4af3d936..c1678c7dbb 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1085,7 +1085,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 697cb22e92..411095f2c2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -631,7 +631,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, 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 c7f963df36..f053494894 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 @@ -8524,7 +8524,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, 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 0fee6fda8c..17afdc5f88 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 @@ -4913,7 +4913,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 991d4de050..a366c99a41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -69,7 +69,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 75ffeb753a..bc081c7be9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts index b1d801fe41..2dac72777e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FileSystemAccessMode = "read" | "write" | "none"; +export type FileSystemAccessMode = "read" | "write" | "deny"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index f00bcfaefb..15eea54e14 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -178,7 +178,7 @@ v2_enum_from_core!( pub enum FileSystemAccessMode from CoreFileSystemAccessMode { Read, Write, - None + Deny } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index fbc692c201..0e7161d5e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -421,7 +421,7 @@ fn additional_file_system_permissions_preserves_canonical_entries() { path: CoreFileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: CoreFileSystemAccessMode::None, + access: CoreFileSystemAccessMode::Deny, }, ], glob_scan_max_depth: NonZeroUsize::new(2), @@ -445,7 +445,7 @@ fn additional_file_system_permissions_preserves_canonical_entries() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), } 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 aa33fc623f..f4593ab7c4 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 @@ -445,7 +445,7 @@ mod thread_processor_behavior_tests { path: FileSystemPath::GlobPattern { pattern: "/tmp/project/**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), NetworkSandboxPolicy::Restricted, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f0eb76d620..3cd4439991 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -836,13 +836,23 @@ "type": "object" }, "FileSystemAccessMode": { - "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", - "enum": [ - "read", - "write", - "none" - ], - "type": "string" + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `deny` beats `write`, and `write` beats `read`.", + "oneOf": [ + { + "enum": [ + "read", + "write" + ], + "type": "string" + }, + { + "description": "`none` is a legacy input alias retained temporarily for compatibility.", + "enum": [ + "deny" + ], + "type": "string" + } + ] }, "FilesystemPermissionToml": { "anyOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 850d9ab571..2297314e06 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -740,6 +740,7 @@ default_permissions = "workspace" [permissions.workspace.filesystem] ":minimal" = "read" +"/tmp/secret.env" = "deny" [permissions.workspace.filesystem.":workspace_roots"] "." = "write" @@ -777,6 +778,10 @@ allow_upstream_proxy = false ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), + ( + "/tmp/secret.env".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Deny), + ), ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ @@ -1700,7 +1705,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), - ("**/*.env".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), ])), )]), }), @@ -1739,7 +1744,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: path: FileSystemPath::GlobPattern { pattern: expected_pattern, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }) ); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dbd6cad470..336d54b382 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2031,7 +2031,7 @@ fn apply_managed_filesystem_constraints( path: codex_protocol::permissions::FileSystemPath::GlobPattern { pattern: deny_read.as_str().to_string(), }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, } } else { let Ok(path) = AbsolutePathBuf::try_from(deny_read.as_str()) else { @@ -2039,7 +2039,7 @@ fn apply_managed_filesystem_constraints( }; codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { path }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, } }; if !file_system_sandbox_policy diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 9f8fcd9ee3..4235cd0f65 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -243,7 +243,7 @@ pub(crate) fn compile_permission_profile( push_warning( startup_warnings, format!( - "Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported." + "Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `deny` globs are supported." ), ); } @@ -414,7 +414,7 @@ fn compile_filesystem_permission( Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true, Some(_) => false, }; - if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern { + if has_glob && *access == FileSystemAccessMode::Deny && can_compile_as_pattern { // Scoped glob syntax is a first-class filesystem policy // pattern entry. Literal scoped paths continue through the // exact-path parser so existing path semantics stay intact. @@ -449,7 +449,7 @@ fn compile_filesystem_access_path( return compile_filesystem_path(path, startup_warnings); } - if access == FileSystemAccessMode::None { + if access == FileSystemAccessMode::Deny { // At this point `path` is an unscoped filesystem table key. Top-level // glob deny entries still go through the absolute-path parser before // becoming policy patterns; relative project-root glob syntax is @@ -520,10 +520,10 @@ fn compile_scoped_filesystem_pattern( // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the // sandbox policy does not express yet. - if access != FileSystemAccessMode::None { + if access != FileSystemAccessMode::Deny { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("filesystem glob subpath `{subpath}` only supports `none` access"), + format!("filesystem glob subpath `{subpath}` only supports `deny` access"), )); } let subpath = parse_relative_subpath(subpath)?; @@ -559,7 +559,7 @@ fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io: Err(io::Error::new( io::ErrorKind::InvalidInput, format!( - "filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access" + "filesystem glob path `{path}` only supports `deny` access; use an exact path or trailing `/**` for `{access}` subtree access" ), )) } @@ -569,7 +569,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> for (path, permission) in &filesystem.entries { match permission { FilesystemPermissionToml::Access(access) => { - if *access != FileSystemAccessMode::None + if *access != FileSystemAccessMode::Deny && contains_glob_chars(remove_trailing_glob_suffix(path)) { patterns.push(path.clone()); @@ -577,7 +577,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> } FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { - if *access != FileSystemAccessMode::None + if *access != FileSystemAccessMode::Deny && contains_glob_chars(remove_trailing_glob_suffix(subpath)) { patterns.push(format!("{path}/{subpath}")); @@ -597,7 +597,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) - let mut patterns = Vec::new(); for (path, permission) in &filesystem.entries { match permission { - FilesystemPermissionToml::Access(FileSystemAccessMode::None) => { + FilesystemPermissionToml::Access(FileSystemAccessMode::Deny) => { if path.contains("**") { patterns.push(path.clone()); } @@ -605,7 +605,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) - FilesystemPermissionToml::Access(_) => {} FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { - if *access == FileSystemAccessMode::None && subpath.contains("**") { + if *access == FileSystemAccessMode::Deny && subpath.contains("**") { patterns.push(format!("{path}/{subpath}")); } } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 51cf13912e..d6f358017e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -326,7 +326,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ - ("**/*.env".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), ("docs/**".to_string(), FileSystemAccessMode::Read), ("src/**/*.rs".to_string(), FileSystemAccessMode::Write), ])), @@ -340,7 +340,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths "/tmp/**/*.log".to_string(), ":workspace_roots/src/**/*.rs".to_string() ], - "`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn" + "`deny` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn" ); } @@ -351,8 +351,8 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() { entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ - ("**/*.env".to_string(), FileSystemAccessMode::None), - ("*.pem".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), + ("*.pem".to_string(), FileSystemAccessMode::Deny), ])), )]), }; @@ -435,7 +435,7 @@ fn read_write_glob_patterns_still_reject_non_subpath_globs() { assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( err.to_string() - .contains("filesystem glob path `src/**/*.rs` only supports `none` access"), + .contains("filesystem glob path `src/**/*.rs` only supports `deny` access"), "{err}" ); } diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 268cbae971..23db019bb9 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -698,7 +698,7 @@ fn windows_restricted_token_rejects_unreadable_split_carveouts() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { path: blocked }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); @@ -846,7 +846,7 @@ fn windows_elevated_supports_unreadable_split_carveouts() { path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&blocked) .expect("absolute blocked"), }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); @@ -908,7 +908,7 @@ fn windows_elevated_supports_unreadable_globs() { path: codex_protocol::permissions::FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, }, ]); diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index ce51d8c437..ca15d0fe42 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -218,7 +218,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { path: FileSystemPath::Path { path: blocked_absolute, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c41d5e348e..8e1bd6f96a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3522,7 +3522,7 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }; let mut existing_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( @@ -7137,7 +7137,7 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy path: FileSystemPath::GlobPattern { pattern: format!("{cwd_display}/**/*.env"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); policy } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 69281acc7d..fe956faa37 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -441,7 +441,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, 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 2846cacb70..ad1702b207 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2106,7 +2106,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { path: FileSystemPath::GlobPattern { pattern: "**/.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); let expected_network_sandbox_policy = NetworkSandboxPolicy::from(&expected_sandbox); let expected_permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index e8a14083fc..43d8e46952 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -281,7 +281,7 @@ fn shell_request_escalation_execution_is_explicit() { path: FileSystemPath::Path { path: AbsolutePathBuf::from_absolute_path("/tmp/secret").unwrap(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let network_sandbox_policy = NetworkSandboxPolicy::Restricted; diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 99b43699a9..f2e4c4cd64 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -143,7 +143,7 @@ fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]); assert_eq!( diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 1e325e1bb5..f5a35003f8 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -148,7 +148,7 @@ fn workspace_write_with_unreadable_path(unreadable_path: AbsolutePathBuf) -> Per path: FileSystemPath::Path { path: unreadable_path, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Special { diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7b523a4686..7949abfb60 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -380,7 +380,7 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { path: FileSystemPath::GlobPattern { pattern: format!("{}/**/*.env", config.cwd.as_path().display()), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); config .permissions diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 98755201c9..d61e427386 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -2774,7 +2774,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { path: FileSystemPath::GlobPattern { pattern: format!("{}/**/*.env", config.cwd.as_path().display()), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); config .permissions diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index e631e537c6..6306b88b8f 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -527,7 +527,7 @@ async fn view_image_tool_applies_local_sandbox_read_denies() -> anyhow::Result<( path: FileSystemPath::Path { path: denied_path.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); let permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index d0919b3dee..189248be3a 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -91,13 +91,13 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: future_secret, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let permission_profile = PermissionProfile::from_runtime_permissions( @@ -173,11 +173,11 @@ async fn windows_elevated_enforces_exact_and_glob_deny_read_policy() -> anyhow:: path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: exact_secret }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let permission_profile = PermissionProfile::from_runtime_permissions( diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 22e7d72531..25a3814fb5 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -1347,7 +1347,7 @@ mod tests { fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry { FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, } } @@ -1547,7 +1547,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -1672,7 +1672,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -1989,7 +1989,7 @@ mod tests { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".secrets".into())), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2172,7 +2172,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2299,7 +2299,7 @@ mod tests { path: FileSystemPath::Path { path: blocked.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { @@ -2368,7 +2368,7 @@ mod tests { path: FileSystemPath::Path { path: blocked.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { @@ -2447,7 +2447,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: allowed }, @@ -2498,7 +2498,7 @@ mod tests { path: FileSystemPath::Path { path: blocked.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2542,7 +2542,7 @@ mod tests { path: FileSystemPath::Path { path: blocked_file.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 87e4ce68ae..e18f2a17b4 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -805,7 +805,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { path: FileSystemPath::Path { path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let permission_profile = PermissionProfile::from_runtime_permissions( @@ -873,7 +873,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() { path: FileSystemPath::Path { path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { @@ -931,7 +931,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() { path: FileSystemPath::Path { path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let permission_profile = PermissionProfile::from_runtime_permissions( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5335f7c46b..d1d2abe755 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -126,7 +126,7 @@ impl FileSystemPermissions { match entry.access { FileSystemAccessMode::Read => read.push(path.clone()), FileSystemAccessMode::Write => write.push(path.clone()), - FileSystemAccessMode::None => return None, + FileSystemAccessMode::Deny => return None, } } @@ -1788,7 +1788,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]); file_system_sandbox_policy.glob_scan_max_depth = Some(2); diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 1d8a1e707e..f90859a92b 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -95,7 +95,7 @@ impl NetworkSandboxPolicy { /// Access mode for a filesystem entry. /// /// When two equally specific entries target the same path, we compare these by -/// conflict precedence rather than by capability breadth: `none` beats +/// conflict precedence rather than by capability breadth: `deny` beats /// `write`, and `write` beats `read`. #[derive( Debug, @@ -117,12 +117,14 @@ impl NetworkSandboxPolicy { pub enum FileSystemAccessMode { Read, Write, - None, + /// `none` is a legacy input alias retained temporarily for compatibility. + #[serde(alias = "none")] + Deny, } impl FileSystemAccessMode { pub fn can_read(self) -> bool { - !matches!(self, FileSystemAccessMode::None) + !matches!(self, FileSystemAccessMode::Deny) } pub fn can_write(self) -> bool { @@ -341,7 +343,7 @@ pub enum FileSystemPath { path: AbsolutePathBuf, }, /// A git-style glob pattern. Pattern entries currently support - /// FileSystemAccessMode::None only. + /// FileSystemAccessMode::Deny only. GlobPattern { pattern: String, }, @@ -412,7 +414,7 @@ impl FileSystemSandboxPolicy { && self .entries .iter() - .any(|entry| entry.access == FileSystemAccessMode::None) + .any(|entry| entry.access == FileSystemAccessMode::Deny) } pub fn from_legacy_sandbox_policy_preserving_deny_entries( @@ -429,7 +431,7 @@ impl FileSystemSandboxPolicy { for deny_entry in existing .entries .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) + .filter(|entry| entry.access == FileSystemAccessMode::Deny) { if !rebuilt.entries.iter().any(|entry| entry == deny_entry) { rebuilt.entries.push(deny_entry.clone()); @@ -445,7 +447,7 @@ impl FileSystemSandboxPolicy { let has_deny_read_entries = existing .entries .iter() - .any(|entry| entry.access == FileSystemAccessMode::None); + .any(|entry| entry.access == FileSystemAccessMode::Deny); if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries { *self = Self::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -466,7 +468,7 @@ impl FileSystemSandboxPolicy { for deny_entry in existing .entries .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) + .filter(|entry| entry.access == FileSystemAccessMode::Deny) { if !self.entries.iter().any(|entry| entry == deny_entry) { self.entries.push(deny_entry.clone()); @@ -492,7 +494,7 @@ impl FileSystemSandboxPolicy { FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry), FileSystemPath::GlobPattern { .. } => true, FileSystemPath::Special { value } => match value { - FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None, + FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::Deny, FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => { false } @@ -654,7 +656,7 @@ impl FileSystemSandboxPolicy { } let Some(path) = resolve_candidate_path(path, cwd) else { - return FileSystemAccessMode::None; + return FileSystemAccessMode::Deny; }; self.resolved_entries_with_cwd(cwd) @@ -662,7 +664,7 @@ impl FileSystemSandboxPolicy { .filter(|entry| path.as_path().starts_with(entry.path.as_path())) .max_by_key(resolved_entry_precedence) .map(|entry| entry.access) - .unwrap_or(FileSystemAccessMode::None) + .unwrap_or(FileSystemAccessMode::Deny) } pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { @@ -1062,7 +1064,7 @@ impl FileSystemSandboxPolicy { dedup_absolute_paths( self.resolved_entries_with_cwd(cwd) .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) + .filter(|entry| entry.access == FileSystemAccessMode::Deny) .filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd)) // Restricted policies already deny reads outside explicit allow roots, // so materializing the filesystem root here would erase narrower @@ -1083,7 +1085,7 @@ impl FileSystemSandboxPolicy { let mut patterns = self .entries .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) + .filter(|entry| entry.access == FileSystemAccessMode::Deny) .filter_map(|entry| match &entry.path { FileSystemPath::GlobPattern { pattern } => { Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd)) @@ -1142,7 +1144,7 @@ impl FileSystemSandboxPolicy { } FileSystemPath::Special { value } => match value { FileSystemSpecialPath::Root => match entry.access { - FileSystemAccessMode::None => {} + FileSystemAccessMode::Deny => {} FileSystemAccessMode::Read => {} FileSystemAccessMode::Write => { unbridgeable_root_write = true; @@ -2191,7 +2193,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2253,7 +2255,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2354,7 +2356,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2401,7 +2403,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2443,7 +2445,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: alias }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2503,7 +2505,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2549,7 +2551,7 @@ mod tests { path: FileSystemPath::Path { path: docs_private.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { @@ -2569,7 +2571,7 @@ mod tests { ); assert_eq!( policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()), - FileSystemAccessMode::None + FileSystemAccessMode::Deny ); assert_eq!( policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()), @@ -2710,7 +2712,7 @@ mod tests { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, @@ -2746,14 +2748,14 @@ mod tests { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); assert!(!policy.has_full_disk_write_access()); assert_eq!( policy.resolve_access_with_cwd(root.as_path(), cwd.path()), - FileSystemAccessMode::None + FileSystemAccessMode::Deny ); } @@ -2877,7 +2879,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: project_roots_glob_pattern(Path::new("**/*.env")), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -2920,7 +2922,7 @@ mod tests { .to_string_lossy() .into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { @@ -2931,7 +2933,7 @@ mod tests { .to_string_lossy() .into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]) ); @@ -2944,7 +2946,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: project_roots_glob_pattern(Path::new("**/*.env")), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]); let actual = policy.materialize_project_roots_with_cwd(cwd.path()); @@ -2957,7 +2959,7 @@ mod tests { .to_string_lossy() .into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]) ); } @@ -3006,7 +3008,7 @@ mod tests { #[test] fn file_system_access_mode_orders_by_conflict_precedence() { assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read); - assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write); + assert!(FileSystemAccessMode::Deny > FileSystemAccessMode::Write); } #[test] @@ -3016,7 +3018,7 @@ mod tests { path: FileSystemPath::Path { path: denied.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]); let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -3031,7 +3033,7 @@ mod tests { == FileSystemPath::Path { path: denied.clone(), } - && entry.access == FileSystemAccessMode::None + && entry.access == FileSystemAccessMode::Deny }), "expected explicit deny entry to be preserved" ); @@ -3064,14 +3066,14 @@ mod tests { path: FileSystemPath::Path { path: AbsolutePathBuf::try_from(path).expect("absolute deny path"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }]) } fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry { FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d5bf200386..637302da8b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -4211,7 +4211,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -4268,7 +4268,7 @@ mod tests { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: secret }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -5163,7 +5163,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: "/tmp/private/**/*.txt".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ])), model: "gpt-5".to_string(), @@ -5191,7 +5191,7 @@ mod tests { "type": "glob_pattern", "pattern": "/tmp/private/**/*.txt" }, - "access": "none" + "access": "deny" }] }) ); diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 31f74b9c0a..b00e9779be 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -183,7 +183,7 @@ fn transform_additional_permissions_preserves_denied_entries() { path: FileSystemPath::Path { path: denied_path.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let permissions = PermissionProfile::from_runtime_permissions( @@ -228,7 +228,7 @@ fn transform_additional_permissions_preserves_denied_entries() { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: allowed_path }, diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 3dad862bea..958a259182 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -28,7 +28,7 @@ pub fn normalize_additional_permissions( let glob_scan_max_depth = file_system.glob_scan_max_depth; for entry in file_system.entries { if matches!(&entry.path, FileSystemPath::GlobPattern { .. }) - && entry.access != FileSystemAccessMode::None + && entry.access != FileSystemAccessMode::Deny { return Err( "glob file system permissions only support deny-read entries".to_string(), @@ -221,7 +221,7 @@ fn effective_glob_scan_depth( entries .iter() .any(|entry| { - entry.access == FileSystemAccessMode::None + entry.access == FileSystemAccessMode::Deny && matches!(&entry.path, FileSystemPath::GlobPattern { .. }) }) .then_some(match depth { @@ -273,7 +273,7 @@ fn retain_constraining_deny_entries( let mut retained_entries = Vec::new(); for entry in source_entries .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) + .filter(|entry| entry.access == FileSystemAccessMode::Deny) { if !deny_entry_constrains_accepted_grant(entry, accepted_entries, cwd) { continue; @@ -340,7 +340,7 @@ fn access_covers(requested: FileSystemAccessMode, granted: FileSystemAccessMode) match granted { FileSystemAccessMode::Read => requested.can_read(), FileSystemAccessMode::Write => requested.can_write(), - FileSystemAccessMode::None => false, + FileSystemAccessMode::Deny => false, } } diff --git a/codex-rs/sandboxing/src/policy_transforms_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs index 3404eee3da..ffe504c02b 100644 --- a/codex-rs/sandboxing/src/policy_transforms_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -58,7 +58,7 @@ fn root_write_policy_with_carveouts_still_uses_platform_sandbox() { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -187,7 +187,7 @@ fn normalize_additional_permissions_preserves_deny_globs() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }], glob_scan_max_depth: std::num::NonZeroUsize::new(2), }), @@ -203,7 +203,7 @@ fn normalize_additional_permissions_preserves_deny_globs() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }], glob_scan_max_depth: std::num::NonZeroUsize::new(2), }), @@ -427,7 +427,7 @@ fn intersect_permission_profiles_materializes_cwd_deny_entries() { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, @@ -448,7 +448,7 @@ fn intersect_permission_profiles_materializes_cwd_deny_entries() { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: request_cwd }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, @@ -480,7 +480,7 @@ fn intersect_permission_profiles_drops_deny_entries_without_filesystem_grants() }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: secret }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, @@ -520,7 +520,7 @@ fn intersect_permission_profiles_rejects_concrete_grants_matched_by_requested_de path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: std::num::NonZeroUsize::new(2), @@ -558,7 +558,7 @@ fn intersect_permission_profiles_materializes_relative_deny_globs_for_reuse() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }; let permissions = PermissionProfile { file_system: Some(FileSystemPermissions { @@ -586,7 +586,7 @@ fn intersect_permission_profiles_materializes_relative_deny_globs_for_reuse() { path: FileSystemPath::GlobPattern { pattern: request_cwd.join("**/*.env").to_string_lossy().into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: std::num::NonZeroUsize::new(2), @@ -657,7 +657,7 @@ fn intersect_permission_profiles_uses_granted_bounded_glob_scan_depth() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }; let requested = PermissionProfile { file_system: Some(FileSystemPermissions { @@ -689,7 +689,7 @@ fn intersect_permission_profiles_uses_granted_bounded_glob_scan_depth() { .to_string_lossy() .into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: std::num::NonZeroUsize::new(4), @@ -712,7 +712,7 @@ fn intersect_permission_profiles_uses_granted_unbounded_glob_scan_depth() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }; let requested = PermissionProfile { file_system: Some(FileSystemPermissions { @@ -744,7 +744,7 @@ fn intersect_permission_profiles_uses_granted_unbounded_glob_scan_depth() { .to_string_lossy() .into_owned(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, @@ -775,7 +775,7 @@ fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roo path: FileSystemPath::Path { path: denied_path.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), &FileSystemPermissions::from_read_write_roots( @@ -787,7 +787,7 @@ fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roo assert_eq!( merged_policy.entries.contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }), true ); @@ -806,7 +806,7 @@ fn merge_file_system_policy_with_additional_permissions_carries_bounded_glob_sca path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }; let merged_policy = merge_file_system_policy_with_additional_permissions( &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { @@ -853,7 +853,7 @@ fn effective_file_system_sandbox_policy_returns_base_policy_without_additional_p }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -883,7 +883,7 @@ fn effective_file_system_sandbox_policy_merges_additional_write_roots() { path: FileSystemPath::Path { path: denied_path.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); let additional_permissions = PermissionProfile { @@ -900,7 +900,7 @@ fn effective_file_system_sandbox_policy_merges_additional_write_roots() { assert_eq!( effective_policy.entries.contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }), true ); diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index af1a1ccf46..d905d0adcf 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -195,7 +195,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: unreadable }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -267,7 +267,7 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: unreadable }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]); @@ -372,7 +372,7 @@ fn unreadable_glob_policy_includes_canonicalized_static_prefix() { let mut policy = FileSystemSandboxPolicy::default(); policy.entries.push(FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }); let seatbelt_policy = build_seatbelt_unreadable_glob_policy(&policy, temp_dir.path()); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 6f2b3ec7f1..4e7f0c6741 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -294,7 +294,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: "**/.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 22d9f842f1..17b17f3598 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -960,7 +960,7 @@ pub(crate) fn format_additional_permissions_rule( .entries .iter() .flatten() - .filter(|entry| entry.access == FileSystemAccessMode::None), + .filter(|entry| entry.access == FileSystemAccessMode::Deny), ); if !denied_reads.is_empty() { parts.push(format!("deny read {denied_reads}")); @@ -1820,7 +1820,7 @@ mod tests { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), glob_scan_max_depth: None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 15f5431dad..4cd4cf4509 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -112,7 +112,7 @@ async fn submission_includes_configured_active_permission_profile() { path: FileSystemPath::GlobPattern { pattern: "/home/user/project/secrets/**".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 628c5abb43..5ba32ead56 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -245,7 +245,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { path: FileSystemPath::GlobPattern { pattern: "**/.secret".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, diff --git a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs index 1e24ca096e..ce07df1726 100644 --- a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs +++ b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs @@ -43,7 +43,7 @@ pub fn resolve_windows_deny_read_paths( path: FileSystemPath::GlobPattern { pattern: pattern.clone(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }) .collect(), ); @@ -205,7 +205,7 @@ mod tests { fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry { FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, } } @@ -214,7 +214,7 @@ mod tests { path: FileSystemPath::Path { path: AbsolutePathBuf::from_absolute_path(path).expect("absolute path"), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, } } From d269aa2af922224229169f89c0319c13694a1f67 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Tue, 19 May 2026 11:22:00 -0700 Subject: [PATCH 111/114] Harden CLI rate limit window labels (#22929) ## Context The CLI rate-limit surfaces previously described usage windows as fixed 5-hour and weekly limits. We want the CLI to display whatever supported rate-limit period the server returns instead of assuming a 5-hour/1-week pair. This supports generalized Codex rate-limit periods. ## Summary - Formats CLI rate-limit warning/status labels only for the supported returned window durations: approximate 5h, daily, weekly, monthly, and annual. - Uses generic fallback copy when a primary or secondary window has no duration, so missing secondary protection data does not produce stale weekly copy. - Uses generic fallback copy for unsupported window durations instead of adding arbitrary hourly, multi-day, multi-week, or multi-year labels. - Updates status line and terminal title setup descriptions/previews to talk about primary/secondary usage limits rather than fixed 5h/weekly limits. - Adds rendered insta snapshot coverage for the updated rate-limit status surfaces and `/status` fallback labels. ## Tests Tested locally: - one primary window - one secondary window - primary and secondary window --- codex-rs/tui/src/app_server_session.rs | 52 +++- .../tui/src/bottom_pane/status_line_setup.rs | 42 +++- .../src/bottom_pane/status_surface_preview.rs | 90 ++++++- codex-rs/tui/src/bottom_pane/title_setup.rs | 37 ++- codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui/src/chatwidget/rate_limits.rs | 57 +++-- ...__status_line_setup_popup_rate_limits.snap | 20 ++ ...__status_surface_previews_rate_limits.snap | 6 + ...erminal_title_setup_popup_rate_limits.snap | 20 ++ .../tui/src/chatwidget/status_controls.rs | 23 +- .../tui/src/chatwidget/status_surfaces.rs | 120 ++++++++-- .../src/chatwidget/tests/status_and_layout.rs | 222 ++++++++++++++++++ .../tests/status_surface_previews.rs | 119 +++++++++- codex-rs/tui/src/status/rate_limits.rs | 47 ++-- ..._limit_labels_for_unsupported_windows.snap | 24 ++ codex-rs/tui/src/status/tests.rs | 67 ++++++ 16 files changed, 860 insertions(+), 89 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 22e4776559..5ea7bccf97 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1588,10 +1588,19 @@ async fn thread_session_state_from_thread_response( pub(crate) fn app_server_rate_limit_snapshots( response: GetAccountRateLimitsResponse, ) -> Vec { - let mut snapshots = Vec::new(); - snapshots.push(response.rate_limits); + let primary_limit_id = response.rate_limits.limit_id.clone(); + let mut snapshots = vec![response.rate_limits]; if let Some(by_limit_id) = response.rate_limits_by_limit_id { - snapshots.extend(by_limit_id.into_values()); + snapshots.extend(by_limit_id.into_iter().filter_map(|(limit_id, snapshot)| { + if primary_limit_id.as_deref().is_some_and(|primary_limit_id| { + primary_limit_id == limit_id + || Some(primary_limit_id) == snapshot.limit_id.as_deref() + }) { + None + } else { + Some(snapshot) + } + })); } snapshots } @@ -1631,6 +1640,43 @@ mod tests { .expect("config should build") } + fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some(limit_id.to_string()), + limit_name: None, + primary: Some(codex_app_server_protocol::RateLimitWindow { + used_percent: 0, + window_duration_mins: Some(10_080), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + } + } + + #[test] + fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() { + let response = GetAccountRateLimitsResponse { + rate_limits: rate_limit_snapshot("codex"), + rate_limits_by_limit_id: Some(HashMap::from([ + ("codex".to_string(), rate_limit_snapshot("codex")), + ("other".to_string(), rate_limit_snapshot("other")), + ])), + }; + + let snapshots = app_server_rate_limit_snapshots(response); + + assert_eq!( + snapshots + .iter() + .map(|snapshot| snapshot.limit_id.as_deref()) + .collect::>(), + vec![Some("codex"), Some("other")] + ); + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5ce13cd214..a44919c216 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -15,7 +15,7 @@ //! - Permissions profile //! - Approval mode //! - Context usage (remaining %, used %, window size) -//! - Usage limits (5-hour, weekly) +//! - Usage limits (primary, secondary) //! - Session info (thread title, thread ID, tokens used) //! - Application version @@ -100,10 +100,10 @@ pub(crate) enum StatusLineItem { #[strum(to_string = "context-used", serialize = "context-usage")] ContextUsed, - /// Remaining usage on the 5-hour rate limit. + /// Remaining usage on the primary rate limit. FiveHourLimit, - /// Remaining usage on the weekly rate limit. + /// Remaining usage on the secondary rate limit. WeeklyLimit, /// Codex application version. @@ -163,10 +163,10 @@ impl StatusLineItem { "Percentage of context window used (omitted when unknown)" } StatusLineItem::FiveHourLimit => { - "Remaining usage on 5-hour usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } StatusLineItem::WeeklyLimit => { - "Remaining usage on weekly usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } StatusLineItem::CodexVersion => "Codex application version", StatusLineItem::ContextWindowSize => { @@ -268,7 +268,11 @@ impl StatusLineSetupView { if !used_ids.insert(item_id.clone()) { continue; } - items.push(Self::status_line_select_item(item, /*enabled*/ true)); + items.push(Self::status_line_select_item( + item, + /*enabled*/ true, + &preview_data, + )); } } @@ -277,7 +281,11 @@ impl StatusLineSetupView { if used_ids.contains(&item_id) { continue; } - items.push(Self::status_line_select_item(item, /*enabled*/ false)); + items.push(Self::status_line_select_item( + item, + /*enabled*/ false, + &preview_data, + )); } Self { @@ -324,11 +332,25 @@ impl StatusLineSetupView { } /// Converts a [`StatusLineItem`] into a [`MultiSelectItem`] for the picker. - fn status_line_select_item(item: StatusLineItem, enabled: bool) -> MultiSelectItem { + fn status_line_select_item( + item: StatusLineItem, + enabled: bool, + preview_data: &StatusSurfacePreviewData, + ) -> MultiSelectItem { + let default_name = item.to_string(); + let default_description = item.description(); + let (name, description) = match item { + StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => ( + preview_data.rate_limit_item_name(item.preview_item(), &default_name), + preview_data.rate_limit_item_description(item.preview_item(), default_description), + ), + _ => (default_name, default_description.to_string()), + }; + MultiSelectItem { id: item.to_string(), - name: item.to_string(), - description: Some(item.description().to_string()), + name, + description: Some(description), enabled, orderable: true, section_break_after: false, diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 9f0f8ed5fb..71353c298a 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -51,8 +51,8 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::ApprovalMode => "on-request", StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", StatusSurfacePreviewItem::ContextUsed => "Context 0% used", - StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", - StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%", + StatusSurfacePreviewItem::FiveHourLimit => "primary 0%", + StatusSurfacePreviewItem::WeeklyLimit => "secondary 0%", StatusSurfacePreviewItem::CodexVersion => "0.0.0", StatusSurfacePreviewItem::ContextWindowSize => "0 window", StatusSurfacePreviewItem::UsedTokens => "0 used", @@ -169,10 +169,49 @@ impl StatusSurfacePreviewData { ); } + pub(crate) fn suppress_placeholder(&mut self, item: StatusSurfacePreviewItem) { + if self + .values + .get(&item) + .is_some_and(|value| value.is_placeholder) + { + self.values.remove(&item); + } + } + + pub(crate) fn rate_limit_item_name( + &self, + item: StatusSurfacePreviewItem, + fallback: &str, + ) -> String { + self.live_value_for(item) + .and_then(rate_limit_preview_copy) + .map(|copy| copy.name.to_string()) + .unwrap_or_else(|| fallback.to_string()) + } + + pub(crate) fn rate_limit_item_description( + &self, + item: StatusSurfacePreviewItem, + fallback: &str, + ) -> String { + self.live_value_for(item) + .and_then(rate_limit_preview_copy) + .map(|copy| copy.description.to_string()) + .unwrap_or_else(|| fallback.to_string()) + } + pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { self.values.get(&item).map(|value| value.text.as_str()) } + fn live_value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { + self.values + .get(&item) + .filter(|value| !value.is_placeholder) + .map(|value| value.text.as_str()) + } + pub(crate) fn status_line_for_items( &self, items: I, @@ -188,3 +227,50 @@ impl StatusSurfacePreviewData { status_line_from_segments(segments, use_theme_colors) } } + +struct RateLimitPreviewCopy { + name: &'static str, + description: &'static str, +} + +fn rate_limit_preview_copy(value: &str) -> Option { + let value = value.trim_start(); + if value.starts_with("secondary usage ") { + Some(RateLimitPreviewCopy { + name: "secondary-usage-limit", + description: "Remaining usage on the secondary usage limit (omitted when unavailable)", + }) + } else if value.starts_with("usage ") { + Some(RateLimitPreviewCopy { + name: "usage-limit", + description: "Remaining usage on the primary usage limit (omitted when unavailable)", + }) + } else if value.starts_with("5h ") { + Some(RateLimitPreviewCopy { + name: "five-hour-limit", + description: "Remaining usage on the 5-hour usage limit (omitted when unavailable)", + }) + } else if value.starts_with("daily ") { + Some(RateLimitPreviewCopy { + name: "daily-limit", + description: "Remaining usage on the daily usage limit (omitted when unavailable)", + }) + } else if value.starts_with("weekly ") { + Some(RateLimitPreviewCopy { + name: "weekly-limit", + description: "Remaining usage on the weekly usage limit (omitted when unavailable)", + }) + } else if value.starts_with("monthly ") { + Some(RateLimitPreviewCopy { + name: "monthly-limit", + description: "Remaining usage on the monthly usage limit (omitted when unavailable)", + }) + } else if value.starts_with("annual ") { + Some(RateLimitPreviewCopy { + name: "annual-limit", + description: "Remaining usage on the annual usage limit (omitted when unavailable)", + }) + } else { + None + } +} diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index f37991bc00..b9ab39054e 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -60,9 +60,9 @@ pub(crate) enum TerminalTitleItem { /// Percentage of context window used. #[strum(to_string = "context-used", serialize = "context-usage")] ContextUsed, - /// Remaining usage on the 5-hour rate limit. + /// Remaining usage on the primary rate limit. FiveHourLimit, - /// Remaining usage on the weekly rate limit. + /// Remaining usage on the secondary rate limit. WeeklyLimit, /// Codex application version. CodexVersion, @@ -107,10 +107,10 @@ impl TerminalTitleItem { "Percentage of context window used (omitted when unknown)" } TerminalTitleItem::FiveHourLimit => { - "Remaining usage on 5-hour usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } TerminalTitleItem::WeeklyLimit => { - "Remaining usage on weekly usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } TerminalTitleItem::CodexVersion => "Codex application version", TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)", @@ -259,11 +259,13 @@ impl TerminalTitleSetupView { .collect::>(); let items = selected_items .into_iter() - .map(|item| Self::title_select_item(item, /*enabled*/ true)) + .map(|item| Self::title_select_item(item, /*enabled*/ true, &preview_data)) .chain( TerminalTitleItem::iter() .filter(|item| !selected_set.contains(item)) - .map(|item| Self::title_select_item(item, /*enabled*/ false)), + .map(|item| { + Self::title_select_item(item, /*enabled*/ false, &preview_data) + }), ) .collect(); @@ -309,11 +311,28 @@ impl TerminalTitleSetupView { } } - fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem { + fn title_select_item( + item: TerminalTitleItem, + enabled: bool, + preview_data: &StatusSurfacePreviewData, + ) -> MultiSelectItem { + let default_name = item.to_string(); + let default_description = item.description(); + let (name, description) = match item.preview_item() { + Some( + preview_item @ (StatusSurfacePreviewItem::FiveHourLimit + | StatusSurfacePreviewItem::WeeklyLimit), + ) => ( + preview_data.rate_limit_item_name(preview_item, &default_name), + preview_data.rate_limit_item_description(preview_item, default_description), + ), + _ => (default_name, default_description.to_string()), + }; + MultiSelectItem { id: item.to_string(), - name: item.to_string(), - description: Some(item.description().to_string()), + name, + description: Some(description), enabled, orderable: true, section_break_after: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f58dec02e4..240b72ab9d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -375,8 +375,9 @@ use self::rate_limits::RateLimitErrorKind; use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; use self::rate_limits::app_server_rate_limit_error_kind; -pub(crate) use self::rate_limits::get_limits_duration; +pub(crate) use self::rate_limits::fallback_limit_label; use self::rate_limits::is_app_server_cyber_policy_error; +pub(crate) use self::rate_limits::limit_label_for_window; mod realtime; mod rendering; mod replay; diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 7eaca908c6..544611f4f0 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -7,6 +7,8 @@ pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; +const SECONDARY_LIMIT_FALLBACK_LABEL: &str = "secondary usage"; #[derive(Default)] pub(super) struct RateLimitWarningState { @@ -40,9 +42,8 @@ impl RateLimitWarningState { self.secondary_index += 1; } if let Some(threshold) = highest_secondary { - let limit_label = secondary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); + let limit_label = + limit_label_for_window(secondary_window_minutes, /*is_secondary*/ true); let remaining_percent = 100.0 - threshold; warnings.push(format!( "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." @@ -59,9 +60,8 @@ impl RateLimitWarningState { self.primary_index += 1; } if let Some(threshold) = highest_primary { - let limit_label = primary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); + let limit_label = + limit_label_for_window(primary_window_minutes, /*is_secondary*/ false); let remaining_percent = 100.0 - threshold; warnings.push(format!( "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." @@ -73,28 +73,51 @@ impl RateLimitWarningState { } } -pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { +pub(crate) fn limit_label_for_window(window_minutes: Option, is_secondary: bool) -> String { + window_minutes + .and_then(get_limits_duration) + .unwrap_or_else(|| fallback_limit_label(is_secondary).to_string()) +} + +pub(crate) fn get_limits_duration(windows_minutes: i64) -> Option { const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_5_HOURS: i64 = 5 * MINUTES_PER_HOUR; const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: i64 = 3; + const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY; let windows_minutes = windows_minutes.max(0); - if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { - let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); - let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); - format!("{hours}h") - } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { - "weekly".to_string() - } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { - "monthly".to_string() + if is_approximate_window(windows_minutes, MINUTES_PER_5_HOURS) { + Some("5h".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_DAY) { + Some("daily".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_WEEK) { + Some("weekly".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_MONTH) { + Some("monthly".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_YEAR) { + Some("annual".to_string()) } else { - "annual".to_string() + None } } +pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str { + if is_secondary { + SECONDARY_LIMIT_FALLBACK_LABEL + } else { + PRIMARY_LIMIT_FALLBACK_LABEL + } +} + +fn is_approximate_window(minutes: i64, expected_minutes: i64) -> bool { + let minutes = minutes as f64; + let expected_minutes = expected_minutes as f64; + minutes >= expected_minutes * 0.95 && minutes <= expected_minutes * 1.05 +} + #[derive(Default)] pub(super) enum RateLimitSwitchPromptState { #[default] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap new file mode 100644 index 0000000000..58d5065ff3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the weekly usage limit (omitted when unavailable) + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + monthly 65% · weekly 50% + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap new file mode 100644 index 0000000000..eb6ec60729 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: monthly 65% · weekly 50% +terminal title: monthly 65% | weekly 50% diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap new file mode 100644 index 0000000000..e05b295554 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the weekly usage limit (omitted when unavailable) + [ ] app-name Codex app name + [ ] project-name Project name (falls back to current directory name) + [ ] current-dir Current working directory + [ ] activity Spinner while working, action-required message while blocked. + [ ] run-state Compact session run-state text (Ready, Working, Thinking) + [ ] thread-title Current thread title, or thread identifier when unnamed + + monthly 65% | weekly 50% + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 70f213bee4..b1970d48dd 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -298,10 +298,25 @@ impl ChatWidget { } pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { - StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { - self.status_surface_preview_value_for_item(item) - .map(|value| (item, value)) - })) + let mut preview_data = StatusSurfacePreviewData::from_iter( + StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + }), + ); + + if self.rate_limit_snapshots_by_limit_id.contains_key("codex") { + for item in [ + StatusSurfacePreviewItem::FiveHourLimit, + StatusSurfacePreviewItem::WeeklyLimit, + ] { + if self.status_surface_preview_value_for_item(item).is_none() { + preview_data.suppress_placeholder(item); + } + } + } + + preview_data } pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e34fe4688a..2b2b7d52df 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,6 +6,8 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; +use crate::chatwidget::limit_label_for_window; +use crate::chatwidget::rate_limits::get_limits_duration; use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; @@ -603,26 +605,20 @@ impl ChatWidget { .status_line_context_used_percent() .map(|used| format!("Context {used}% used")), StatusLineItem::FiveHourLimit => { - let window = self + let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") - .and_then(|s| s.primary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - self.status_line_limit_display(window, &label) + .and_then(five_hour_status_window)?; + let label = limit_label_for_window(window.window_minutes, is_secondary); + self.status_line_limit_display(Some(window), &label) } StatusLineItem::WeeklyLimit => { - let window = self + let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") - .and_then(|s| s.secondary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - self.status_line_limit_display(window, &label) + .and_then(weekly_status_window)?; + let label = limit_label_for_window(window.window_minutes, is_secondary); + self.status_line_limit_display(Some(window), &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), StatusLineItem::ContextWindowSize => self @@ -893,6 +889,102 @@ impl ChatWidget { } } +fn five_hour_status_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + find_primary_codex_window(snapshot, "5h") + .or_else(|| secondary_window_with_label_when_weekly_is_available(snapshot, "5h")) + .or_else(|| non_weekly_primary_window(snapshot)) + .or_else(|| non_weekly_secondary_window_when_primary_is_weekly(snapshot)) +} + +fn weekly_status_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + find_codex_window(snapshot, "weekly") + .or_else(|| snapshot.secondary.as_ref().map(|window| (window, true))) +} + +fn find_codex_window<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { + if let Some(primary) = snapshot.primary.as_ref() + && matches_window_label(primary, label) + { + return Some((primary, false)); + } + + if let Some(secondary) = snapshot.secondary.as_ref() + && matches_window_label(secondary, label) + { + return Some((secondary, true)); + } + + None +} + +fn find_primary_codex_window<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { + let primary = snapshot.primary.as_ref()?; + if matches_window_label(primary, label) { + Some((primary, false)) + } else { + None + } +} + +fn secondary_window_with_label_when_weekly_is_available<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { + find_codex_window(snapshot, "weekly")?; + + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, label) { + Some((secondary, true)) + } else { + None + } +} + +fn non_weekly_primary_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let primary = snapshot.primary.as_ref()?; + if matches_window_label(primary, "weekly") { + None + } else { + Some((primary, false)) + } +} + +fn non_weekly_secondary_window_when_primary_is_weekly( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let primary = snapshot.primary.as_ref()?; + if !matches_window_label(primary, "weekly") { + return None; + } + + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, "weekly") { + None + } else { + Some((secondary, true)) + } +} + +fn matches_window_label(window: &RateLimitWindowDisplay, label: &str) -> bool { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some(label) +} + fn permissions_display(config: &Config) -> String { let active_permission_profile = config.permissions.active_permission_profile(); if let Some(active_permission_profile) = active_permission_profile.as_ref() diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 799237aa54..306a90d782 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,6 +1,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; +use crate::chatwidget::rate_limits::get_limits_duration; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; @@ -516,6 +517,227 @@ async fn test_rate_limit_warnings_monthly() { ); } +#[test] +fn rate_limit_duration_labels_only_render_supported_windows() { + assert_eq!(get_limits_duration(2 * 60), None); + assert_eq!(get_limits_duration(24 * 60).as_deref(), Some("daily")); + assert_eq!( + get_limits_duration(365 * 24 * 60).as_deref(), + Some("annual") + ); +} + +#[tokio::test] +async fn test_rate_limit_warnings_use_generic_fallback_labels() { + let mut state = RateLimitWarningState::default(); + + assert_eq!( + state.take_warnings( + /*secondary_used_percent*/ Some(75.0), + /*secondary_window_minutes*/ None, + /*primary_used_percent*/ Some(75.0), + /*primary_window_minutes*/ None, + ), + vec![ + String::from( + "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + ), + String::from( + "Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.", + ), + ], + ); +} + +#[tokio::test] +async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window() { + let mut state = RateLimitWarningState::default(); + + assert_eq!( + state.take_warnings( + /*secondary_used_percent*/ Some(75.0), + /*secondary_window_minutes*/ Some(2 * 60), + /*primary_used_percent*/ None, + /*primary_window_minutes*/ None, + ), + vec![String::from( + "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + )], + ); +} + +#[tokio::test] +async fn status_line_uses_secondary_fallback_for_unsupported_window() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(2 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("secondary usage 50%".to_string()) + ); +} + +#[tokio::test] +async fn status_line_legacy_limit_items_prefer_matching_windows() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 94, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 40, + window_duration_mins: Some(5 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("5h 60%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 6%".to_string()) + ); +} + +#[tokio::test] +async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 94, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("monthly 65%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 6%".to_string()) + ); +} + +#[tokio::test] +async fn status_line_five_hour_item_omits_weekly_only_limit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 9, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + None + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 91%".to_string()) + ); +} + +#[tokio::test] +async fn status_line_single_monthly_primary_omits_weekly_limit_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("monthly 65%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + None + ); +} + +#[tokio::test] +async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + None + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("monthly 65%".to_string()) + ); +} + #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8837f04d8d..b9433ace84 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -10,12 +10,15 @@ fn line_text(line: Line<'static>) -> String { .collect() } -fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { +fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option { let preview_data = chat.status_surface_preview_data(); - let preview = preview_data + preview_data .status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true) - .expect("status preview line"); - line_text(preview) + .map(line_text) +} + +fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { + status_preview_line_option(chat, items).expect("status preview line") } fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String { @@ -58,6 +61,26 @@ fn cache_project_root(chat: &mut ChatWidget, root_name: &str) { }); } +fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); +} + #[tokio::test] async fn status_surface_preview_lines_live_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -179,6 +202,79 @@ async fn status_surface_preview_lines_mixed_snapshot() { assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot); } +#[tokio::test] +async fn status_surface_preview_lines_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit], + &[ + TerminalTitleItem::FiveHourLimit, + TerminalTitleItem::WeeklyLimit, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_rate_limits", snapshot); +} + +#[tokio::test] +async fn status_surface_preview_omits_unavailable_rate_limit_items() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 9, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + status_preview_line_option(&mut chat, &[StatusLineItem::FiveHourLimit]), + None + ); + assert_eq!( + status_preview_line( + &mut chat, + &[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit] + ), + "weekly 91%" + ); + assert_eq!( + title_preview_line( + &mut chat, + &[ + TerminalTitleItem::FiveHourLimit, + TerminalTitleItem::WeeklyLimit + ], + ), + "weekly 91%" + ); +} + +#[tokio::test] +async fn status_line_setup_popup_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + chat.config.tui_status_line = Some(vec![ + "five-hour-limit".to_string(), + "weekly-limit".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_rate_limits", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_line_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -247,6 +343,21 @@ async fn terminal_title_setup_popup_mixed_snapshot() { ); } +#[tokio::test] +async fn terminal_title_setup_popup_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + chat.config.tui_terminal_title = Some(vec![ + "five-hour-limit".to_string(), + "weekly-limit".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_rate_limits", + terminal_title_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn missing_project_root_uses_different_status_and_title_preview_sources() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 3be813beb9..1146bb8a31 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -5,7 +5,8 @@ //! //! The key contract is that time-sensitive values are interpreted relative to a caller-provided //! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle. -use crate::chatwidget::get_limits_duration; +use crate::chatwidget::fallback_limit_label; +use crate::chatwidget::limit_label_for_window; use crate::text_formatting::capitalize_first; use super::helpers::format_reset_timestamp; @@ -23,7 +24,7 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░"; #[derive(Debug, Clone)] pub(crate) struct StatusRateLimitRow { - /// Human-readable row label, such as `"5h limit"` or `"Credits"`. + /// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`. pub label: String, /// Value payload for the row. pub value: StatusRateLimitValue, @@ -92,9 +93,9 @@ pub(crate) struct RateLimitSnapshotDisplay { pub limit_name: String, /// Local timestamp representing when this display snapshot was captured. pub captured_at: DateTime, - /// Primary usage window (typically short duration). + /// Primary usage window. pub primary: Option, - /// Secondary usage window (typically weekly). + /// Secondary usage window. pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, @@ -188,21 +189,13 @@ pub(crate) fn compose_rate_limit_data_many( .primary .as_ref() .map(|window| { - window - .window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()) + limit_label_for_window(window.window_minutes, /*is_secondary*/ false) }) .map(|label| capitalize_first(&label)); let secondary_label = snapshot .secondary .as_ref() - .map(|window| { - window - .window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()) - }) + .map(|window| limit_label_for_window(window.window_minutes, /*is_secondary*/ true)) .map(|label| capitalize_first(&label)); let window_count = usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some()); @@ -220,12 +213,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - primary_label.clone().unwrap_or_else(|| "5h".to_string()) + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) ) } else { format!( "{} limit", - primary_label.clone().unwrap_or_else(|| "5h".to_string()) + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) ) }; rows.push(StatusRateLimitRow { @@ -242,16 +239,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - secondary_label - .clone() - .unwrap_or_else(|| "weekly".to_string()) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) } else { format!( "{} limit", - secondary_label - .clone() - .unwrap_or_else(|| "weekly".to_string()) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) }; rows.push(StatusRateLimitRow { @@ -420,7 +417,7 @@ mod tests { secondary: Some(RateLimitWindowDisplay { used_percent: 40.0, resets_at: Some("later".to_string()), - window_minutes: None, + window_minutes: Some(2 * 60), }), credits: None, }; @@ -434,8 +431,8 @@ mod tests { labels, vec![ "codex-other limit".to_string(), - "1h limit".to_string(), - "Weekly limit".to_string(), + "Usage limit".to_string(), + "Secondary usage limit".to_string(), ] ); } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap new file mode 100644 index 0000000000..f983cf478d --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Usage limit: [█████████████░░░░░░░] 65% left │ +│ (resets 07:08 on 7 May) │ +│ Secondary usage limit: [██████████░░░░░░░░░░] 50% left │ +│ (resets 07:08 on 8 May) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 286767e647..e6fa90071a 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -878,6 +878,73 @@ async fn status_snapshot_includes_monthly_limit() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(2 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)), + }), + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(3 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)), + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_shows_unlimited_credits() { let temp_home = TempDir::new().expect("temp home"); From d661ab70eda6bde758d7e48d6bf3638523cb014c Mon Sep 17 00:00:00 2001 From: Abhinav Date: Tue, 19 May 2026 12:45:08 -0700 Subject: [PATCH 112/114] Add SubagentStart hook (#22782) # What `SubagentStart` runs once when Codex creates a thread-spawned subagent, before that child sends its first model request. Thread-spawned subagents use `SubagentStart` instead of the normal root-agent `SessionStart` hook. Configured handlers match on the subagent `agent_type`, using the same value passed to `spawn_agent`. When no agent type is specified, Codex uses the default agent type. Hook input includes the normal session-start fields plus: - `agent_id`: the child thread id. - `agent_type`: the resolved subagent type. `SubagentStart` may return `hookSpecificOutput.additionalContext`. That context is added to the child conversation before the first model request. # Lifecycle Scope Only thread-spawned subagents run `SubagentStart`. Internal/system subagents such as Review, Compact, MemoryConsolidation, and Other do not run normal `SessionStart` hooks and do not run `SubagentStart`. This avoids exposing synthetic matcher labels for internal implementation paths. Also the `SessionStart` hook no longer fires for subagents, this matches behavior with other coding agents' implementation # Stack 1. This PR: add `SubagentStart`. 2. #22873: add `SubagentStop`. 3. #22882: add subagent identity to normal hook inputs. --- codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../v2/ConfigRequirementsReadResponse.json | 7 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/json/v2/HooksListResponse.json | 1 + .../schema/json/v2/PluginReadResponse.json | 1 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ManagedHooksRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 3 + .../src/protocol/v2/hook.rs | 2 +- .../src/config/external_agent_config_tests.rs | 2 +- .../request_processors/config_processor.rs | 2 + codex-rs/config/src/hook_config.rs | 9 +- codex-rs/core/config.schema.json | 7 + codex-rs/core/src/hook_runtime.rs | 30 ++- codex-rs/core/src/session/tests.rs | 12 +- .../tests/suite/subagent_notifications.rs | 195 ++++++++++++++++ ...mission-request.command.output.schema.json | 1 + .../post-tool-use.command.output.schema.json | 1 + .../pre-tool-use.command.output.schema.json | 1 + .../session-start.command.output.schema.json | 1 + .../subagent-start.command.input.schema.json | 63 ++++++ .../subagent-start.command.output.schema.json | 64 ++++++ ...r-prompt-submit.command.output.schema.json | 1 + codex-rs/hooks/src/engine/dispatcher.rs | 3 +- codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/output_parser.rs | 29 ++- codex-rs/hooks/src/engine/schema_loader.rs | 12 + codex-rs/hooks/src/events/common.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 208 +++++++++++++++--- codex-rs/hooks/src/lib.rs | 8 +- codex-rs/hooks/src/schema.rs | 62 ++++++ codex-rs/protocol/src/protocol.rs | 1 + .../tui/src/bottom_pane/hooks_browser_view.rs | 2 + ...ser_view__tests__hooks_browser_events.snap | 1 + ...sts__hooks_browser_events_with_issues.snap | 1 + ...oks_browser_events_with_review_column.snap | 1 + ...s__hooks_popup_shows_list_diagnostics.snap | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/history_cell/hook_cell.rs | 1 + 43 files changed, 716 insertions(+), 44 deletions(-) create mode 100644 codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d107a5a367..f7c64e0e74 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -990,6 +990,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index c1678c7dbb..b9058aa1ce 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1740,6 +1740,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" 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 f053494894..e219f42551 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 @@ -9624,6 +9624,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" @@ -10514,6 +10515,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10541,6 +10548,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" 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 17afdc5f88..fb1a8d2505 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 @@ -6124,6 +6124,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" @@ -7063,6 +7064,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7090,6 +7097,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 41bd337dec..577cd7bbc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -261,6 +261,12 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -288,6 +294,7 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", "UserPromptSubmit" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index f63e6a5cfe..164b4865fb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -14,6 +14,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index f8eeecfe4d..924d522a82 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -14,6 +14,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index f2a7c80cf0..913962b603 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -29,6 +29,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 0c9f897dc9..6d2a0b5d56 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -46,6 +46,7 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 91c2def709..b12f47169f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index cde0e4a503..2b54af3c6d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index a34e6c530f..0b7741c656 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -423,6 +423,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "UserPromptSubmit")] #[ts(rename = "UserPromptSubmit")] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart")] + #[ts(rename = "SubagentStart")] + pub subagent_start: Vec, #[serde(rename = "Stop")] #[ts(rename = "Stop")] pub stop: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 4a07bd495b..8163e9f51a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, Stop } ); diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 8435f9baf5..07b09c5a1e 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -422,7 +422,7 @@ async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() { fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), - r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"SubagentStart":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, ) .expect("write hooks"); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 5054ea248d..486970f929 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -454,6 +454,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = hooks; @@ -467,6 +468,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + subagent_start: map_hook_matcher_groups_to_api(subagent_start), stop: map_hook_matcher_groups_to_api(stop), } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 869761bdaa..4d1329229f 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -45,6 +45,8 @@ pub struct HookEventsToml { pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, } @@ -59,6 +61,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = self; pre_tool_use.is_empty() @@ -68,6 +71,7 @@ impl HookEventsToml { && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() + && subagent_start.is_empty() && stop.is_empty() } @@ -80,6 +84,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, } = self; [ @@ -90,6 +95,7 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, stop, ] .into_iter() @@ -98,7 +104,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 9] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -107,6 +113,7 @@ impl HookEventsToml { (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::SubagentStart, self.subagent_start), (HookEventName::Stop, self.stop), ] } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3cd4439991..00fc4931a4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1122,6 +1122,13 @@ }, "type": "array" }, + "SubagentStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 8cba0f9fad..72bfc1de1c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -13,6 +13,7 @@ use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; +use codex_hooks::StartHookTarget; use codex_hooks::StopOutcome; use codex_hooks::StopRequest; use codex_hooks::UserPromptSubmitOutcome; @@ -30,6 +31,8 @@ use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookStartedEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use serde_json::Value; use crate::context::ContextualUserFragment; @@ -100,6 +103,30 @@ pub(crate) async fn run_pending_session_start_hooks( return false; }; + // Pending session-start hooks are reused to dispatch thread-spawn subagent + // starts. Other subagent sessions are internal/system work and do not run + // start hooks. + let target = match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) + if matches!( + session_start_source, + codex_hooks::SessionStartSource::Startup + ) => + { + let agent_type = agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + StartHookTarget::SubagentStart { + turn_id: turn_context.sub_id.clone(), + agent_id: sess.thread_id().to_string(), + agent_type, + } + } + SessionSource::SubAgent(_) => return false, + _ => StartHookTarget::SessionStart { + source: session_start_source, + }, + }; let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), #[allow(deprecated)] @@ -107,7 +134,7 @@ pub(crate) async fn run_pending_session_start_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - source: session_start_source, + target, }; let hooks = sess.hooks(); let preview_runs = hooks.preview_session_start(&request); @@ -607,6 +634,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", }; let hook_source = match run.source { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8e1bd6f96a..88e9092da3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -530,7 +530,9 @@ async fn preview_session_start_hooks( transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::StartHookTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }), ) } @@ -1296,7 +1298,9 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::StartHookTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }; assert!(session.hooks().preview_session_start(&request).is_empty()); @@ -1401,7 +1405,9 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { transcript_path: None, model: "gpt-5.2".to_string(), permission_mode: "default".to_string(), - source: codex_hooks::SessionStartSource::Startup, + target: codex_hooks::StartHookTarget::SessionStart { + source: codex_hooks::SessionStartSource::Startup, + }, }; assert!(session.hooks().preview_session_start(&request).is_empty()); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index b648cb3d44..25d16faa51 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -42,6 +42,7 @@ const REQUESTED_MODEL: &str = "gpt-5.4"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; +const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req @@ -100,6 +101,96 @@ fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) Ok(()) } +fn write_subagent_start_hooks(home: &Path) -> Result<()> { + let session_start_script_path = home.join("session_start_hook.py"); + let session_start_log_path = home.join("session_start_hook_log.jsonl"); + let session_start_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{session_start_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + session_start_log_path = session_start_log_path.display(), + ); + + let start_script_path = home.join("subagent_start_hook.py"); + let start_log_path = home.join("subagent_start_hook_log.jsonl"); + let start_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{start_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "additionalContext": {SUBAGENT_START_CONTEXT:?}}}}})) +"#, + start_log_path = start_log_path.display(), + ); + + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "matcher": "startup", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", session_start_script_path.display()), + }] + }], + "SubagentStart": [{ + "matcher": "worker", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", start_script_path.display()), + }] + }] + } + }); + + fs::write(&session_start_script_path, session_start_script)?; + fs::write(&start_script_path, start_script)?; + fs::write(home.join("hooks.json"), hooks.to_string())?; + Ok(()) +} + +fn read_hook_log(home: &Path, filename: &str) -> Result> { + let path = home.join(filename); + if !path.exists() { + return Ok(Vec::new()); + } + fs::read_to_string(path)? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(Into::into)) + .collect() +} + +async fn wait_for_hook_log( + home: &Path, + filename: &str, + expected_len: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let inputs = read_hook_log(home, filename)?; + if inputs.len() >= expected_len { + return Ok(inputs); + } + if Instant::now() >= deadline { + anyhow::bail!( + "expected at least {expected_len} entries in {filename}, got {}", + inputs.len() + ); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -279,6 +370,110 @@ async fn spawn_child_and_capture_snapshot( .await) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_start_replaces_session_start_and_injects_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "agent_type": "worker", + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V1_NAMESPACE, + "spawn_agent", + &spawn_args, + ), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) + && body_contains(req, SUBAGENT_START_CONTEXT) + && !body_contains(req, "") + && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done"), + ev_completed("resp-child-1"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let test = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_subagent_start_hooks(home) { + panic!("failed to write subagent hook fixture: {error}"); + } + }) + .with_config(|config| { + core_test_support::hooks::trust_discovered_hooks(config); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + test.submit_turn(TURN_1_PROMPT).await?; + let child_requests = wait_for_requests(&child_request_log).await?; + assert_eq!(child_requests.len(), 1); + + let start_inputs = wait_for_hook_log( + test.codex_home_path(), + "subagent_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; + assert_eq!(start_inputs.len(), 1); + assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); + let spawned_id = wait_for_spawned_thread_id(&test).await?; + assert_eq!( + start_inputs[0]["agent_id"].as_str(), + Some(spawned_id.as_str()) + ); + + let session_start_inputs = wait_for_hook_log( + test.codex_home_path(), + "session_start_hook_log.jsonl", + /*expected_len*/ 1, + ) + .await?; + assert_eq!(session_start_inputs.len(), 1); + assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); + assert_ne!( + session_start_inputs[0]["session_id"].as_str(), + Some(spawned_id.as_str()) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index c89f80a3b0..44e775a03d 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index 2b64c0ad00..b762c549c5 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -17,6 +17,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index 3ab17b9f5c..c4c4132448 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 4d6ba033fe..d8a0f46482 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -11,6 +11,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json new file mode 100644 index 0000000000..2f6edd6038 --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStart", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_type", + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "transcript_path", + "turn_id" + ], + "title": "subagent-start.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json new file mode 100644 index 0000000000..16140f326b --- /dev/null +++ b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "UserPromptSubmit", + "SubagentStart", + "Stop" + ], + "type": "string" + }, + "SessionStartHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/SessionStartHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-start.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 6baceb149f..d4d66d2976 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -17,6 +17,7 @@ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop" ], "type": "string" diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 7243fc6eab..51c6d6672e 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -49,6 +49,7 @@ pub(crate) fn select_handlers_for_matcher_inputs( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart | HookEventName::PreCompact | HookEventName::PostCompact => { if matcher_inputs.is_empty() { @@ -139,7 +140,7 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart => HookScope::Thread, + HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PermissionRequest | HookEventName::PostToolUse diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8a4a11a5d4..7cbbd7a027 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -70,6 +70,7 @@ impl ConfiguredHandler { codex_protocol::protocol::HookEventName::PostCompact => "post-compact", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", + codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", codex_protocol::protocol::HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 48ad1aff2d..1ea44c0903 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -86,17 +86,34 @@ use crate::schema::PreToolUseDecisionWire; use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; +use crate::schema::SubagentStartCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; pub(crate) fn parse_session_start(stdout: &str) -> Option { let wire: SessionStartCommandOutputWire = parse_json(stdout)?; - let additional_context = wire - .hook_specific_output - .and_then(|output| output.additional_context); - Some(SessionStartOutput { - universal: UniversalOutput::from(wire.universal), + Some(session_start_output( + wire.universal, + wire.hook_specific_output, + )) +} + +pub(crate) fn parse_subagent_start(stdout: &str) -> Option { + let wire: SubagentStartCommandOutputWire = parse_json(stdout)?; + Some(session_start_output( + wire.universal, + wire.hook_specific_output, + )) +} + +fn session_start_output( + universal: HookUniversalOutputWire, + hook_specific_output: Option, +) -> SessionStartOutput { + let additional_context = hook_specific_output.and_then(|output| output.additional_context); + SessionStartOutput { + universal: UniversalOutput::from(universal), additional_context, - }) + } } pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 704edea4ba..2b1994f28d 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -16,6 +16,8 @@ pub(crate) struct GeneratedHookSchemas { pub pre_compact_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, + pub subagent_start_command_input: Value, + pub subagent_start_command_output: Value, pub user_prompt_submit_command_input: Value, pub user_prompt_submit_command_output: Value, pub stop_command_input: Value, @@ -73,6 +75,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { "session-start.command.output", include_str!("../../schema/generated/session-start.command.output.schema.json"), ), + subagent_start_command_input: parse_json_schema( + "subagent-start.command.input", + include_str!("../../schema/generated/subagent-start.command.input.schema.json"), + ), + subagent_start_command_output: parse_json_schema( + "subagent-start.command.output", + include_str!("../../schema/generated/subagent-start.command.output.schema.json"), + ), user_prompt_submit_command_input: parse_json_schema( "user-prompt-submit.command.input", include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"), @@ -118,6 +128,8 @@ mod tests { assert_eq!(schemas.pre_compact_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); + assert_eq!(schemas.subagent_start_command_input["type"], "object"); + assert_eq!(schemas.subagent_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_output["type"], "object"); assert_eq!(schemas.stop_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index e79a139b79..f96de58020 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -104,6 +104,7 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart + | HookEventName::SubagentStart | HookEventName::PreCompact | HookEventName::PostCompact => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 88bd9c00a0..2904497b40 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -15,7 +15,9 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::schema::NullableString; use crate::schema::SessionStartCommandInput; +use crate::schema::SubagentStartCommandInput; #[derive(Debug, Clone, Copy)] pub enum SessionStartSource { @@ -41,7 +43,35 @@ pub struct SessionStartRequest { pub transcript_path: Option, pub model: String, pub permission_mode: String, - pub source: SessionStartSource, + pub target: StartHookTarget, +} + +#[derive(Debug, Clone)] +pub enum StartHookTarget { + SessionStart { + source: SessionStartSource, + }, + SubagentStart { + turn_id: String, + agent_id: String, + agent_type: String, + }, +} + +impl StartHookTarget { + fn event_name(&self) -> HookEventName { + match self { + Self::SessionStart { .. } => HookEventName::SessionStart, + Self::SubagentStart { .. } => HookEventName::SubagentStart, + } + } + + fn matcher_input(&self) -> &str { + match self { + Self::SessionStart { source } => source.as_str(), + Self::SubagentStart { agent_type, .. } => agent_type.as_str(), + } + } } #[derive(Debug)] @@ -65,8 +95,8 @@ pub(crate) fn preview( ) -> Vec { dispatcher::select_handlers( handlers, - HookEventName::SessionStart, - Some(request.source.as_str()), + request.target.event_name(), + Some(request.target.matcher_input()), ) .into_iter() .map(|handler| dispatcher::running_summary(&handler)) @@ -81,8 +111,8 @@ pub(crate) async fn run( ) -> SessionStartOutcome { let matched = dispatcher::select_handlers( handlers, - HookEventName::SessionStart, - Some(request.source.as_str()), + request.target.event_name(), + Some(request.target.matcher_input()), ); if matched.is_empty() { return SessionStartOutcome { @@ -93,21 +123,58 @@ pub(crate) async fn run( }; } - let input_json = match serde_json::to_string(&SessionStartCommandInput::new( - request.session_id.to_string(), - request.transcript_path.clone(), - request.cwd.display().to_string(), - request.model.clone(), - request.permission_mode.clone(), - request.source.as_str().to_string(), - )) { - Ok(input_json) => input_json, - Err(error) => { - return serialization_failure_outcome(common::serialization_failure_hook_events( - matched, - turn_id, - format!("failed to serialize session start hook input: {error}"), - )); + let (input_json, turn_id) = match request.target { + StartHookTarget::SessionStart { source } => { + let input_json = match serde_json::to_string(&SessionStartCommandInput::new( + request.session_id.to_string(), + request.transcript_path.clone(), + request.cwd.display().to_string(), + request.model.clone(), + request.permission_mode.clone(), + source.as_str().to_string(), + )) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + turn_id, + format!("failed to serialize session start hook input: {error}"), + ), + ); + } + }; + (input_json, turn_id) + } + StartHookTarget::SubagentStart { + turn_id: subagent_turn_id, + agent_id, + agent_type, + } => { + let input = SubagentStartCommandInput { + session_id: request.session_id.to_string(), + turn_id: subagent_turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "SubagentStart".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + agent_id, + agent_type, + }; + let input_json = match serde_json::to_string(&input) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome( + common::serialization_failure_hook_events( + matched, + Some(subagent_turn_id), + format!("failed to serialize subagent start hook input: {error}"), + ), + ); + } + }; + (input_json, Some(subagent_turn_id)) } }; @@ -139,6 +206,12 @@ pub(crate) async fn run( } } +/// Interprets completed `SessionStart` and `SubagentStart` hook runs. +/// +/// The two events have different input payloads but share most output +/// handling: hook JSON can emit warnings/context, invalid JSON-looking stdout +/// fails, and plain stdout becomes model context. Only `SessionStart` honors +/// `continue:false`; `SubagentStart` stays context-injection-only. fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, @@ -162,8 +235,17 @@ fn parse_completed( Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { - } else if let Some(parsed) = output_parser::parse_session_start(&run_result.stdout) - { + } else if let Some(parsed) = match handler.event_name { + HookEventName::SessionStart => { + output_parser::parse_session_start(&run_result.stdout) + } + HookEventName::SubagentStart => { + output_parser::parse_subagent_start(&run_result.stdout) + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, @@ -178,7 +260,9 @@ fn parse_completed( ); } let _ = parsed.universal.suppress_output; - if !parsed.universal.continue_processing { + if handler.event_name == HookEventName::SessionStart + && !parsed.universal.continue_processing + { status = HookRunStatus::Stopped; should_stop = true; stop_reason = parsed.universal.stop_reason.clone(); @@ -189,12 +273,22 @@ fn parse_completed( }); } } - // Preserve plain-text context support without treating malformed JSON as context. } else if output_parser::looks_like_json(&run_result.stdout) { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, - text: "hook returned invalid session start JSON output".to_string(), + text: match handler.event_name { + HookEventName::SessionStart => { + "hook returned invalid session start JSON output" + } + HookEventName::SubagentStart => { + "hook returned invalid subagent start JSON output" + } + event_name => { + panic!("expected start hook event, got {event_name:?}") + } + } + .to_string(), }); } else { let additional_context = trimmed_stdout.to_string(); @@ -354,9 +448,71 @@ mod tests { ); } + #[test] + fn subagent_start_plain_stdout_becomes_model_context() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result(Some(0), "hello from subagent hook\n", ""), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: false, + stop_reason: None, + additional_contexts_for_model: vec!["hello from subagent hook".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "hello from subagent hook".to_string(), + }] + ); + } + + #[test] + fn subagent_start_continue_false_is_ignored() { + let parsed = parse_completed( + &handler_for(HookEventName::SubagentStart), + run_result( + Some(0), + r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#, + "", + ), + /*turn_id*/ Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + SessionStartHandlerData { + should_stop: false, + stop_reason: None, + additional_contexts_for_model: vec!["child context".to_string()], + } + ); + assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "child context".to_string(), + }] + ); + } + fn handler() -> ConfiguredHandler { + handler_for(HookEventName::SessionStart) + } + + fn handler_for(event_name: HookEventName) -> ConfiguredHandler { ConfiguredHandler { - event_name: HookEventName::SessionStart, + event_name, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index ec59368d30..5675d11e71 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -15,7 +15,7 @@ pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. -pub const HOOK_EVENT_NAMES: [&str; 8] = [ +pub const HOOK_EVENT_NAMES: [&str; 9] = [ "PreToolUse", "PermissionRequest", "PostToolUse", @@ -23,6 +23,7 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ "PostCompact", "SessionStart", "UserPromptSubmit", + "SubagentStart", "Stop", ]; @@ -31,13 +32,14 @@ pub const HOOK_EVENT_NAMES: [&str; 8] = [ /// Other events can appear in hooks JSON, but Codex ignores their matcher /// fields because those events do not dispatch against a tool, compaction /// trigger, or session-start source. -pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 6] = [ +pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 7] = [ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", + "SubagentStart", ]; pub use events::compact::PostCompactRequest; @@ -54,6 +56,7 @@ pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; +pub use events::session_start::StartHookTarget; pub use events::stop::StopOutcome; pub use events::stop::StopRequest; pub use events::user_prompt_submit::UserPromptSubmitOutcome; @@ -83,6 +86,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "post_compact", HookEventName::SessionStart => "session_start", HookEventName::UserPromptSubmit => "user_prompt_submit", + HookEventName::SubagentStart => "subagent_start", HookEventName::Stop => "stop", } } diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d4f408d8c3..96d5f790be 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -27,6 +27,8 @@ const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.js const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json"; +const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json"; +const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json"; const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json"; const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json"; @@ -87,6 +89,8 @@ pub(crate) enum HookEventNameWire { SessionStart, #[serde(rename = "UserPromptSubmit")] UserPromptSubmit, + #[serde(rename = "SubagentStart")] + SubagentStart, #[serde(rename = "Stop")] Stop, } @@ -345,6 +349,17 @@ pub(crate) struct SessionStartHookSpecificOutputWire { pub additional_context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.output")] +pub(crate) struct SubagentStartCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub hook_specific_output: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -427,6 +442,24 @@ impl SessionStartCommandInput { } } +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "subagent-start.command.input")] +pub(crate) struct SubagentStartCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "subagent_start_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + pub agent_id: String, + pub agent_type: String, +} + #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(rename = "user-prompt-submit.command.input")] @@ -522,6 +555,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(SUBAGENT_START_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(STOP_INPUT_FIXTURE), schema_json::()?, @@ -613,6 +654,10 @@ fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Sche string_const_schema("UserPromptSubmit") } +fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("SubagentStart") +} + fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("Stop") } @@ -683,7 +728,10 @@ mod tests { use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; use super::STOP_OUTPUT_FIXTURE; + use super::SUBAGENT_START_INPUT_FIXTURE; + use super::SUBAGENT_START_OUTPUT_FIXTURE; use super::StopCommandInput; + use super::SubagentStartCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE; use super::UserPromptSubmitCommandInput; @@ -737,6 +785,12 @@ mod tests { USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => { include_str!("../schema/generated/user-prompt-submit.command.output.schema.json") } + SUBAGENT_START_INPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.input.schema.json") + } + SUBAGENT_START_OUTPUT_FIXTURE => { + include_str!("../schema/generated/subagent-start.command.output.schema.json") + } STOP_INPUT_FIXTURE => { include_str!("../schema/generated/stop.command.input.schema.json") } @@ -772,6 +826,8 @@ mod tests { SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, USER_PROMPT_SUBMIT_OUTPUT_FIXTURE, + SUBAGENT_START_INPUT_FIXTURE, + SUBAGENT_START_OUTPUT_FIXTURE, STOP_INPUT_FIXTURE, STOP_OUTPUT_FIXTURE, ] { @@ -814,6 +870,11 @@ mod tests { .expect("serialize user prompt submit input schema"), ) .expect("parse user prompt submit input schema"); + let subagent_start: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize subagent start input schema"), + ) + .expect("parse subagent start input schema"); let stop: Value = serde_json::from_slice( &schema_json::().expect("serialize stop input schema"), ) @@ -826,6 +887,7 @@ mod tests { &pre_compact, &post_compact, &user_prompt_submit, + &subagent_start, &stop, ] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 637302da8b..2647d3a878 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1337,6 +1337,7 @@ pub enum HookEventName { PostCompact, SessionStart, UserPromptSubmit, + SubagentStart, Stop, } diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 462e9b286e..1202265aa2 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -736,6 +736,7 @@ fn event_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } @@ -749,6 +750,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "After context compaction", HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", + HookEventName::SubagentStart => "When a subagent is created", HookEventName::Stop => "Right before Codex ends its turn", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 34c1d8ceec..836a65c3f1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -14,6 +14,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index 511f173813..fabd9c1707 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -18,6 +18,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index 07a39ebc9c..dd4e5e33b5 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -16,6 +16,7 @@ expression: "render_lines(&view, 112)" PostCompact 0 0 0 After context compaction SessionStart 0 0 0 When a new session starts UserPromptSubmit 0 0 0 When the user submits a prompt + SubagentStart 0 0 0 When a subagent is created Stop 0 0 0 Right before Codex ends its turn Press t to trust all; enter to review hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 5224197d2f..9a532f563d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -17,6 +17,7 @@ expression: popup PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f3abe693aa..3bd0030465 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1571,6 +1571,7 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::PostCompact => "PostCompact", codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", + codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", codex_app_server_protocol::HookEventName::Stop => "Stop", } } diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index e78ce27fb0..824fdc1de8 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -720,6 +720,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", HookEventName::Stop => "Stop", } } From 7f4d7ae3a45473cfe1e6e1e497a51f34f7b089d4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 12:54:03 -0700 Subject: [PATCH 113/114] build: add Codex package builder (#23513) ## Why Codex CLI packaging is currently split across npm staging, standalone installers, and release bundle creation, which makes it hard to define and validate a single valid package directory. This adds the first standalone package builder so later release paths can converge on the same canonical layout. ## What changed - Added `scripts/build_codex_package.py` as the stable executable wrapper around `scripts/codex_package`. - Added modules for CLI parsing, target metadata, grouped cargo builds, package layout validation, and archive writing. - The builder creates a package directory with `codex-package.json`, `bin/`, `codex-resources/`, and `codex-path`, and can serialize it as `.tar.gz`, `.tar.zst`, or `.zip`. - Source-built artifacts are built by one grouped `cargo build`: `codex` for all targets, `bwrap` for Linux, and the Windows sandbox helpers for Windows. `rg` remains an input because it is vendored from upstream rather than built from this repo. - Added `scripts/codex_package/README.md` to document the package layout, source-built artifacts, and cargo profile behavior. ## Verification - Ran wrapper/module syntax compilation. - Ran `scripts/build_codex_package.py --help` from `/private/tmp`. - Ran fake-cargo package/archive builds for macOS, Linux, and Windows target layouts, including an assertion that generated tar archives contain no duplicate member names. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23513). * #23526 * __->__ #23513 --- scripts/build_codex_package.py | 16 ++++ scripts/codex_package/README.md | 39 ++++++++ scripts/codex_package/__init__.py | 1 + scripts/codex_package/archive.py | 87 +++++++++++++++++ scripts/codex_package/cargo.py | 106 +++++++++++++++++++++ scripts/codex_package/cli.py | 105 ++++++++++++++++++++ scripts/codex_package/layout.py | 153 ++++++++++++++++++++++++++++++ scripts/codex_package/targets.py | 114 ++++++++++++++++++++++ 8 files changed, 621 insertions(+) create mode 100755 scripts/build_codex_package.py create mode 100644 scripts/codex_package/README.md create mode 100644 scripts/codex_package/__init__.py create mode 100644 scripts/codex_package/archive.py create mode 100644 scripts/codex_package/cargo.py create mode 100644 scripts/codex_package/cli.py create mode 100644 scripts/codex_package/layout.py create mode 100644 scripts/codex_package/targets.py diff --git a/scripts/build_codex_package.py b/scripts/build_codex_package.py new file mode 100755 index 0000000000..8e1097c488 --- /dev/null +++ b/scripts/build_codex_package.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Build a canonical Codex package directory and optional archive.""" + +from pathlib import Path +import sys + + +# Some developer environments set PYTHONSAFEPATH=1, which prevents Python from +# adding the script directory to sys.path. Add it explicitly so the local helper +# package remains importable when this executable is launched from any cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from codex_package.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md new file mode 100644 index 0000000000..8c3c7ac58b --- /dev/null +++ b/scripts/codex_package/README.md @@ -0,0 +1,39 @@ +# Codex package builder + +This package contains the implementation behind `scripts/build_codex_package.py`. +The top-level script is the stable executable entry point; these modules keep the +package-building logic split by responsibility. + +The builder creates a canonical Codex package directory: + +```text +. +├── codex-package.json +├── bin +│ └── codex[.exe] +├── codex-resources +│ ├── bwrap # Linux only +│ ├── codex-command-runner.exe # Windows only +│ └── codex-windows-sandbox-setup.exe # Windows only +└── codex-path + └── rg[.exe] +``` + +The package directory is the primary artifact. Archive formats such as +`.tar.gz`, `.tar.zst`, and `.zip` are serializations of that directory. + +## Source-built artifacts + +Artifacts built from this repository are always built by the package builder in +one grouped `cargo build` command per package: + +- all targets: `codex` +- Linux targets: `bwrap` +- Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup` + +The default cargo profile is `dev-small` because local iteration should favor +fast, small builds. Release jobs should pass `--cargo-profile release`. + +`rg` is not built from this repository, so it remains an input. If `--rg-bin` is +omitted, the builder looks in the existing `codex-cli/vendor//path/` +location. diff --git a/scripts/codex_package/__init__.py b/scripts/codex_package/__init__.py new file mode 100644 index 0000000000..bf8a68ab78 --- /dev/null +++ b/scripts/codex_package/__init__.py @@ -0,0 +1 @@ +"""Helpers for building canonical Codex package archives.""" diff --git a/scripts/codex_package/archive.py b/scripts/codex_package/archive.py new file mode 100644 index 0000000000..fe09c0a4f7 --- /dev/null +++ b/scripts/codex_package/archive.py @@ -0,0 +1,87 @@ +"""Archive writers for canonical Codex package directories.""" + +import shutil +import subprocess +import tarfile +import tempfile +import zipfile +from pathlib import Path + + +def write_archive(package_dir: Path, archive_path: Path, *, force: bool) -> None: + if is_relative_to(archive_path, package_dir): + raise RuntimeError( + f"Archive output must be outside the package directory: {archive_path}" + ) + + archive_path.parent.mkdir(parents=True, exist_ok=True) + if archive_path.exists(): + if not force: + raise RuntimeError(f"Archive output already exists: {archive_path}") + archive_path.unlink() + + archive_format = archive_format_for_path(archive_path) + if archive_format == "tar.gz": + write_tar_archive(package_dir, archive_path, mode="w:gz") + elif archive_format == "tar.zst": + write_tar_zst_archive(package_dir, archive_path) + elif archive_format == "zip": + write_zip_archive(package_dir, archive_path) + else: + raise AssertionError(f"unexpected archive format: {archive_format}") + + +def is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + +def archive_format_for_path(path: Path) -> str: + suffixes = path.suffixes + if suffixes[-2:] == [".tar", ".gz"] or path.suffix == ".tgz": + return "tar.gz" + if suffixes[-2:] == [".tar", ".zst"]: + return "tar.zst" + if path.suffix == ".zip": + return "zip" + raise RuntimeError( + f"Unsupported archive suffix for {path}. Use .tar.gz, .tgz, .tar.zst, or .zip." + ) + + +def write_tar_archive(package_dir: Path, archive_path: Path, *, mode: str) -> None: + with tarfile.open(archive_path, mode) as archive: + for path in package_entries(package_dir): + archive.add( + path, + arcname=path.relative_to(package_dir), + recursive=False, + ) + + +def write_tar_zst_archive(package_dir: Path, archive_path: Path) -> None: + zstd = shutil.which("zstd") + if zstd is None: + raise RuntimeError("zstd is required to write .tar.zst archives.") + + with tempfile.TemporaryDirectory(prefix="codex-package-archive-") as temp_dir_str: + tar_path = Path(temp_dir_str) / "package.tar" + write_tar_archive(package_dir, tar_path, mode="w") + subprocess.check_call([zstd, "-T0", "-19", "-f", str(tar_path), "-o", str(archive_path)]) + + +def write_zip_archive(package_dir: Path, archive_path: Path) -> None: + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in package_entries(package_dir): + relative_path = path.relative_to(package_dir) + if path.is_dir(): + archive.write(path, f"{relative_path}/") + else: + archive.write(path, relative_path) + + +def package_entries(package_dir: Path) -> list[Path]: + return sorted(package_dir.rglob("*"), key=lambda path: path.relative_to(package_dir).as_posix()) diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py new file mode 100644 index 0000000000..9ee2d2f832 --- /dev/null +++ b/scripts/codex_package/cargo.py @@ -0,0 +1,106 @@ +"""Cargo builds for source-built Codex package artifacts.""" + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .targets import REPO_ROOT +from .targets import TargetSpec + + +CODEX_RS_ROOT = REPO_ROOT / "codex-rs" + + +@dataclass(frozen=True) +class SourceBuildOutputs: + codex_bin: Path + bwrap_bin: Path | None + codex_command_runner_bin: Path | None + codex_windows_sandbox_setup_bin: Path | None + + +def build_source_binaries( + spec: TargetSpec, + *, + cargo: str, + profile: str, +) -> SourceBuildOutputs: + binaries = source_binaries_for_target(spec) + cmd = [ + cargo, + "build", + "--target", + spec.target, + "--profile", + profile, + ] + for binary in binaries: + cmd.extend(["--bin", binary]) + + print("+", " ".join(cmd)) + subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) + + output_dir = cargo_profile_output_dir(spec, profile) + outputs = SourceBuildOutputs( + codex_bin=output_dir / spec.codex_name, + bwrap_bin=output_dir / "bwrap" if spec.is_linux else None, + codex_command_runner_bin=( + output_dir / "codex-command-runner.exe" if spec.is_windows else None + ), + codex_windows_sandbox_setup_bin=( + output_dir / "codex-windows-sandbox-setup.exe" if spec.is_windows else None + ), + ) + validate_source_outputs(outputs) + return outputs + + +def source_binaries_for_target(spec: TargetSpec) -> list[str]: + binaries = ["codex"] + if spec.is_linux: + binaries.append("bwrap") + if spec.is_windows: + binaries.extend( + [ + "codex-command-runner", + "codex-windows-sandbox-setup", + ] + ) + return binaries + + +def cargo_profile_output_dir(spec: TargetSpec, profile: str) -> Path: + target_dir = cargo_target_dir() + return target_dir / spec.target / cargo_profile_dirname(profile) + + +def cargo_target_dir() -> Path: + target_dir = os.environ.get("CARGO_TARGET_DIR") + if target_dir is None: + return CODEX_RS_ROOT / "target" + + path = Path(target_dir) + if path.is_absolute(): + return path + + return CODEX_RS_ROOT / path + + +def cargo_profile_dirname(profile: str) -> str: + if profile == "dev": + return "debug" + if profile == "release": + return "release" + return profile + + +def validate_source_outputs(outputs: SourceBuildOutputs) -> None: + for path in [ + outputs.codex_bin, + outputs.bwrap_bin, + outputs.codex_command_runner_bin, + outputs.codex_windows_sandbox_setup_bin, + ]: + if path is not None and not path.is_file(): + raise RuntimeError(f"cargo build did not produce expected binary: {path}") diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py new file mode 100644 index 0000000000..beddffa3b9 --- /dev/null +++ b/scripts/codex_package/cli.py @@ -0,0 +1,105 @@ +"""Command-line interface for building Codex package directories.""" + +import argparse +from pathlib import Path + +from .archive import write_archive +from .cargo import build_source_binaries +from .layout import build_package_dir +from .layout import prepare_package_dir +from .layout import validate_package_dir +from .targets import TARGET_SPECS +from .targets import PackageInputs +from .targets import resolve_rg_bin + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build a canonical Codex package directory and optional archive.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--target", + required=True, + choices=sorted(TARGET_SPECS), + help="Rust target triple for the package.", + ) + parser.add_argument( + "--version", + default="0.0.0-dev", + help="Codex version to record in codex-package.json.", + ) + parser.add_argument( + "--variant", + default="codex", + help="Package variant to record in codex-package.json.", + ) + parser.add_argument( + "--package-dir", + type=Path, + required=True, + help="Output directory to create as the package root.", + ) + parser.add_argument( + "--archive-output", + type=Path, + help=( + "Optional archive output path. Supported suffixes: .tar.gz, .tgz, " + ".tar.zst, .zip." + ), + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace an existing package directory or archive output.", + ) + parser.add_argument( + "--cargo", + default="cargo", + help="Cargo executable to use for source-built package artifacts.", + ) + parser.add_argument( + "--cargo-profile", + default="dev-small", + help=( + "Cargo profile for source-built package artifacts. Use release for " + "release packages." + ), + ) + parser.add_argument( + "--rg-bin", + type=Path, + help="Path to the ripgrep executable to place in codex-path/.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + spec = TARGET_SPECS[args.target] + package_dir = args.package_dir.resolve() + + source_outputs = build_source_binaries( + spec, + cargo=args.cargo, + profile=args.cargo_profile, + ) + inputs = PackageInputs( + codex_bin=source_outputs.codex_bin, + rg_bin=resolve_rg_bin(spec, args.rg_bin), + bwrap_bin=source_outputs.bwrap_bin, + codex_command_runner_bin=source_outputs.codex_command_runner_bin, + codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin, + ) + prepare_package_dir(package_dir, force=args.force) + build_package_dir(package_dir, args.version, args.variant, spec, inputs) + validate_package_dir(package_dir, spec) + + archive_output = args.archive_output + if archive_output is not None: + archive_path = archive_output.resolve() + write_archive(package_dir, archive_path, force=args.force) + print(f"Built Codex package archive at {archive_path}") + + print(f"Built Codex package directory at {package_dir}") + return 0 diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py new file mode 100644 index 0000000000..faf24ef810 --- /dev/null +++ b/scripts/codex_package/layout.py @@ -0,0 +1,153 @@ +"""Canonical Codex package directory layout.""" + +import json +import shutil +import stat +from pathlib import Path + +from .targets import PackageInputs +from .targets import TargetSpec + + +LAYOUT_VERSION = 1 + + +def prepare_package_dir(package_dir: Path, *, force: bool) -> None: + if package_dir.exists(): + if not package_dir.is_dir(): + raise RuntimeError(f"Package output exists and is not a directory: {package_dir}") + if any(package_dir.iterdir()): + if not force: + raise RuntimeError( + f"Package output directory is not empty: {package_dir}. " + "Pass --force to replace it." + ) + shutil.rmtree(package_dir) + + package_dir.mkdir(parents=True, exist_ok=True) + + +def build_package_dir( + package_dir: Path, + version: str, + variant: str, + spec: TargetSpec, + inputs: PackageInputs, +) -> None: + bin_dir = package_dir / "bin" + resources_dir = package_dir / "codex-resources" + path_dir = package_dir / "codex-path" + bin_dir.mkdir() + resources_dir.mkdir() + path_dir.mkdir() + + copy_executable(inputs.codex_bin, bin_dir / spec.codex_name, is_windows=spec.is_windows) + copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) + + if inputs.bwrap_bin is not None: + copy_executable(inputs.bwrap_bin, resources_dir / "bwrap", is_windows=False) + + if inputs.codex_command_runner_bin is not None: + copy_executable( + inputs.codex_command_runner_bin, + resources_dir / "codex-command-runner.exe", + is_windows=True, + ) + + if inputs.codex_windows_sandbox_setup_bin is not None: + copy_executable( + inputs.codex_windows_sandbox_setup_bin, + resources_dir / "codex-windows-sandbox-setup.exe", + is_windows=True, + ) + + metadata = { + "layoutVersion": LAYOUT_VERSION, + "version": version, + "target": spec.target, + "variant": variant, + "entrypoint": f"bin/{spec.codex_name}", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + } + write_json(package_dir / "codex-package.json", metadata) + + +def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: + required_dirs = [ + Path("bin"), + Path("codex-resources"), + Path("codex-path"), + ] + for relative_dir in required_dirs: + path = package_dir / relative_dir + if not path.is_dir(): + raise RuntimeError(f"Missing package directory: {relative_dir}") + + metadata_path = package_dir / "codex-package.json" + if not metadata_path.is_file(): + raise RuntimeError("Missing package metadata: codex-package.json") + + with open(metadata_path, encoding="utf-8") as fh: + metadata = json.load(fh) + + expected_metadata = { + "layoutVersion": LAYOUT_VERSION, + "target": spec.target, + "entrypoint": f"bin/{spec.codex_name}", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + } + for key, expected in expected_metadata.items(): + actual = metadata.get(key) + if actual != expected: + raise RuntimeError( + f"Invalid package metadata field {key!r}: expected {expected!r}, got {actual!r}" + ) + + required_files = [ + Path("bin") / spec.codex_name, + Path("codex-path") / spec.rg_name, + ] + executable_files = list(required_files) + + if spec.is_linux: + required_files.append(Path("codex-resources") / "bwrap") + executable_files.append(Path("codex-resources") / "bwrap") + + if spec.is_windows: + required_files.extend( + [ + Path("codex-resources") / "codex-command-runner.exe", + Path("codex-resources") / "codex-windows-sandbox-setup.exe", + ] + ) + + for relative_file in required_files: + path = package_dir / relative_file + if not path.is_file(): + raise RuntimeError(f"Missing package file: {relative_file}") + + if not spec.is_windows: + for relative_file in executable_files: + path = package_dir / relative_file + if not is_executable(path): + raise RuntimeError(f"Package file is not executable: {relative_file}") + + +def copy_executable(src: Path, dest: Path, *, is_windows: bool) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + if not is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def write_json(path: Path, value: object) -> None: + with open(path, "w", encoding="utf-8") as out: + json.dump(value, out, indent=2) + out.write("\n") + + +def is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & stat.S_IXUSR) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py new file mode 100644 index 0000000000..bdba8bd766 --- /dev/null +++ b/scripts/codex_package/targets.py @@ -0,0 +1,114 @@ +"""Supported package targets and default binary discovery.""" + +import stat +from dataclasses import dataclass +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = SCRIPT_DIR.parent + + +@dataclass(frozen=True) +class TargetSpec: + target: str + is_windows: bool + is_linux: bool + + @property + def exe_suffix(self) -> str: + return ".exe" if self.is_windows else "" + + @property + def codex_name(self) -> str: + return f"codex{self.exe_suffix}" + + @property + def rg_name(self) -> str: + return f"rg{self.exe_suffix}" + + +@dataclass(frozen=True) +class PackageInputs: + codex_bin: Path + rg_bin: Path + bwrap_bin: Path | None + codex_command_runner_bin: Path | None + codex_windows_sandbox_setup_bin: Path | None + + +TARGET_SPECS: dict[str, TargetSpec] = { + "x86_64-unknown-linux-musl": TargetSpec( + target="x86_64-unknown-linux-musl", + is_windows=False, + is_linux=True, + ), + "aarch64-unknown-linux-musl": TargetSpec( + target="aarch64-unknown-linux-musl", + is_windows=False, + is_linux=True, + ), + "x86_64-apple-darwin": TargetSpec( + target="x86_64-apple-darwin", + is_windows=False, + is_linux=False, + ), + "aarch64-apple-darwin": TargetSpec( + target="aarch64-apple-darwin", + is_windows=False, + is_linux=False, + ), + "x86_64-pc-windows-msvc": TargetSpec( + target="x86_64-pc-windows-msvc", + is_windows=True, + is_linux=False, + ), + "aarch64-pc-windows-msvc": TargetSpec( + target="aarch64-pc-windows-msvc", + is_windows=True, + is_linux=False, + ), +} + + +def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: + return resolve_input_path( + rg_bin, + default_rg_candidates(spec), + "ripgrep executable", + "--rg-bin", + ) + + +def default_rg_candidates(spec: TargetSpec) -> list[Path]: + return [ + REPO_ROOT / "codex-cli" / "vendor" / spec.target / "path" / spec.rg_name, + ] + + +def resolve_input_path( + explicit_path: Path | None, + default_candidates: list[Path], + description: str, + flag_name: str, +) -> Path: + if explicit_path is not None: + path = explicit_path.resolve() + if not path.is_file(): + raise RuntimeError(f"{description} does not exist: {path}") + if not is_executable(path): + raise RuntimeError(f"{description} is not executable: {path}") + return path + + for candidate in default_candidates: + if candidate.is_file(): + return candidate.resolve() + + candidates = "\n".join(f" - {candidate}" for candidate in default_candidates) + raise RuntimeError( + f"Could not find {description}. Pass {flag_name}, or create one of:\n{candidates}" + ) + + +def is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) From 5c43a64e2bb5a83e192b886cb7a9ae63a0046b27 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 19 May 2026 12:55:34 -0700 Subject: [PATCH 114/114] Make local environment optional in EnvironmentManager (#23369) ## Summary - make `EnvironmentManager` local environment/runtime paths optional - simplify constructor surface around snapshot materialization - rename local env accessors to `require_local_environment` / `try_local_environment` ## Validation - devbox Bazel build for touched crate surfaces - `//codex-rs/exec-server:exec-server-unit-tests` - `//codex-rs/app-server-client:app-server-client-unit-tests` - filtered touched `//codex-rs/core:core-unit-tests` cases --- codex-rs/app-server-client/src/lib.rs | 12 +- codex-rs/app-server/src/lib.rs | 4 +- codex-rs/app-server/src/message_processor.rs | 77 ++-- .../src/request_processors/mcp_processor.rs | 37 +- .../request_processors/thread_processor.rs | 10 + .../app-server/tests/suite/v2/command_exec.rs | 40 +++ codex-rs/app-server/tests/suite/v2/fs.rs | 23 ++ .../app-server/tests/suite/v2/process_exec.rs | 28 ++ .../tests/suite/v2/thread_shell_command.rs | 44 +++ .../codex-mcp/src/connection_manager_tests.rs | 106 ++++++ codex-rs/codex-mcp/src/rmcp_client.rs | 28 +- codex-rs/codex-mcp/src/runtime.rs | 219 +++++++++++- codex-rs/core/src/connectors.rs | 15 +- codex-rs/core/src/environment_selection.rs | 11 +- codex-rs/core/src/mcp_tool_call_tests.rs | 16 +- codex-rs/core/src/prompt_debug.rs | 9 +- codex-rs/core/src/session/mcp.rs | 7 +- codex-rs/core/src/session/session.rs | 9 +- codex-rs/core/src/thread_manager_tests.rs | 4 +- codex-rs/core/tests/common/test_codex.rs | 2 +- codex-rs/exec-server/src/environment.rs | 329 ++++++++---------- codex-rs/exec-server/src/environment_toml.rs | 101 +++++- codex-rs/exec/src/lib.rs | 5 +- codex-rs/mcp-server/src/lib.rs | 4 +- codex-rs/thread-manager-sample/src/main.rs | 3 +- codex-rs/tui/src/lib.rs | 8 +- 26 files changed, 853 insertions(+), 298 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 49a7a3b800..c7f5b9889a 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -2178,11 +2178,13 @@ mod tests { let environment_manager = Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"), + Some( + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + ), ) .await, ); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index b62b2bdc40..7b7cbc11c3 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -445,9 +445,9 @@ pub async fn run_main_with_transport_options( arg0_paths.codex_linux_sandbox_exe.clone(), )?; let environment_manager = if loader_overrides.ignore_user_config { - EnvironmentManager::from_env(local_runtime_paths).await + EnvironmentManager::from_env(Some(local_runtime_paths)).await } else { - EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await } .map(Arc::new) .map_err(std::io::Error::other)?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4e2c6f38cc..fdcd44d01a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; @@ -165,10 +166,11 @@ pub(crate) struct MessageProcessor { command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, + environment_manager: Arc, environment_processor: EnvironmentRequestProcessor, external_agent_config_processor: ExternalAgentConfigRequestProcessor, feedback_processor: FeedbackRequestProcessor, - fs_processor: FsRequestProcessor, + fs_processor: Option, git_processor: GitRequestProcessor, initialize_processor: InitializeRequestProcessor, marketplace_processor: MarketplaceRequestProcessor, @@ -272,6 +274,23 @@ pub(crate) struct MessageProcessorArgs { } impl MessageProcessor { + fn fs_processor(&self) -> Result<&FsRequestProcessor, JSONRPCErrorError> { + self.fs_processor + .as_ref() + .ok_or_else(|| internal_error("local filesystem is not configured")) + } + + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + // CCA filters these local-only RPCs before they reach app-server, but + // keep a Codex-side backstop so no-local app-server modes fail safely + // if a client still invokes one directly. + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } + /// Create a new `MessageProcessor`, retaining a handle to the outgoing /// `Sender` so handlers can enqueue messages to be written to stdout. pub(crate) fn new(args: MessageProcessorArgs) -> Self { @@ -301,6 +320,7 @@ impl MessageProcessor { // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); + let environment_manager_for_requests = Arc::clone(&environment_manager); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), @@ -443,7 +463,6 @@ impl MessageProcessor { Some(on_effective_plugins_changed), ); } - let fs_watch_manager = FsWatchManager::new(outgoing.clone()); let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), @@ -461,13 +480,17 @@ impl MessageProcessor { ); let environment_processor = EnvironmentRequestProcessor::new(thread_manager.environment_manager()); - let fs_processor = FsRequestProcessor::new( - thread_manager - .environment_manager() - .local_environment() - .get_filesystem(), - fs_watch_manager, - ); + // `fs/*` is a local-host filesystem surface. Do not construct it when + // the manager intentionally has no local environment. + let fs_processor = thread_manager + .environment_manager() + .try_local_environment() + .map(|environment| { + FsRequestProcessor::new( + environment.get_filesystem(), + FsWatchManager::new(outgoing.clone()), + ) + }); let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( outgoing.clone(), Arc::clone(&config), @@ -482,6 +505,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_manager: environment_manager_for_requests, environment_processor, external_agent_config_processor, feedback_processor, @@ -705,7 +729,9 @@ impl MessageProcessor { ) { session_state.rpc_gate.shutdown().await; self.outgoing.connection_closed(connection_id).await; - self.fs_processor.connection_closed(connection_id).await; + if let Some(fs_processor) = &self.fs_processor { + fs_processor.connection_closed(connection_id).await; + } self.command_exec_processor .connection_closed(connection_id) .await; @@ -911,47 +937,47 @@ impl MessageProcessor { self.environment_processor.environment_add(params).await } ClientRequest::FsReadFile { params, .. } => self - .fs_processor + .fs_processor()? .read_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsWriteFile { params, .. } => self - .fs_processor + .fs_processor()? .write_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsCreateDirectory { params, .. } => self - .fs_processor + .fs_processor()? .create_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsGetMetadata { params, .. } => self - .fs_processor + .fs_processor()? .get_metadata(params) .await .map(|response| Some(response.into())), ClientRequest::FsReadDirectory { params, .. } => self - .fs_processor + .fs_processor()? .read_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsRemove { params, .. } => self - .fs_processor + .fs_processor()? .remove(params) .await .map(|response| Some(response.into())), ClientRequest::FsCopy { params, .. } => self - .fs_processor + .fs_processor()? .copy(params) .await .map(|response| Some(response.into())), ClientRequest::FsWatch { params, .. } => self - .fs_processor + .fs_processor()? .watch(connection_id, params) .await .map(|response| Some(response.into())), ClientRequest::FsUnwatch { params, .. } => self - .fs_processor + .fs_processor()? .unwatch(connection_id, params) .await .map(|response| Some(response.into())), @@ -1280,6 +1306,7 @@ impl MessageProcessor { .await .map(|response| Some(response.into())), ClientRequest::OneOffCommandExec { params, .. } => { + self.require_local_environment()?; self.command_exec_processor .one_off_command_exec(&request_id, params) .await @@ -1299,11 +1326,13 @@ impl MessageProcessor { .command_exec_terminate(request_id.clone(), params) .await } - ClientRequest::ProcessSpawn { params, .. } => self - .process_exec_processor - .process_spawn(request_id.clone(), params) - .await - .map(|()| None), + ClientRequest::ProcessSpawn { params, .. } => { + self.require_local_environment()?; + self.process_exec_processor + .process_spawn(request_id.clone(), params) + .await + .map(|()| None) + } ClientRequest::ProcessWriteStdin { params, .. } => { self.process_exec_processor .process_write_stdin(request_id.clone(), params) diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 548bb5be87..4bb9431f42 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -205,17 +205,14 @@ impl McpRequestProcessor { .await; let auth = self.auth_manager.auth().await; let environment_manager = self.thread_manager.environment_manager(); - let runtime_environment = match environment_manager.default_environment() { - Some(environment) => { - // Status listing has no turn cwd. This fallback is used only - // by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - } - None => McpRuntimeEnvironment::new( - environment_manager.local_environment(), - config.cwd.to_path_buf(), - ), - }; + // Status listing has no turn cwd. Prefer the configured default env, + // then configured local if present; do not manufacture a hidden local + // env in no-local modes. + let runtime_environment = McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ); tokio::spawn(async move { Self::list_mcp_server_status_task( @@ -369,15 +366,15 @@ impl McpRequestProcessor { .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; - let runtime_environment = { - let environment_manager = self.thread_manager.environment_manager(); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - // Resource reads without a thread have no turn cwd. This fallback - // is used only by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - }; + let environment_manager = self.thread_manager.environment_manager(); + // Resource reads without a thread have no turn cwd. Prefer the + // configured default env, then configured local if present; do not + // manufacture a hidden local env in no-local modes. + let runtime_environment = McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ); let request_id = request_id.clone(); tokio::spawn(async move { 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 dd2dd6a57e..f89f4a6503 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1738,6 +1738,16 @@ impl ThreadRequestProcessor { if command.is_empty() { return Err(invalid_request("command must not be empty")); } + // `thread/shellCommand` is app-server's local-host shell escape hatch, + // not the normal turn-selected shell tool path. + if self + .thread_manager + .environment_manager() + .try_local_environment() + .is_none() + { + return Err(internal_error("local environment is not configured")); + } let (_, thread) = self.load_thread(&thread_id).await?; self.submit_core_op( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index b4038299cf..9757767c9f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -400,6 +401,45 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu Ok(()) } +#[tokio::test] +async fn command_exec_returns_error_when_local_environment_is_disabled() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index a780a51e0b..3ca511a482 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -119,6 +120,28 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let absolute_file = codex_home.path().join("absolute.txt"); + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(absolute_file), + }) + .await?; + expect_error_message(&mut mcp, read_id, "local filesystem is not configured").await?; + + Ok(()) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_reports_symlink() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index 5dd3e84b4c..d273257e6e 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::ProcessExitedNotification; use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -102,6 +103,33 @@ async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Resu Ok(()) } +#[tokio::test] +async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_request_id = mcp + .send_process_spawn_request(process_spawn_params( + "disabled-process".to_string(), + codex_home.path(), + vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + )?) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(process_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index b7cfba2f95..4ab677aca1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::shell::default_user_shell; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::FEATURES; use codex_features::Feature; use pretty_assertions::assert_eq; @@ -182,6 +183,49 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut Ok(()) } +#[tokio::test] +async fn thread_shell_command_returns_error_when_local_environment_is_disabled() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let server = create_mock_responses_server_sequence(vec![]).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new_with_env( + codex_home.as_path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id, + command: "pwd".to_string(), + }) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(shell_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let tmp = TempDir::new()?; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 50b1bf6c12..952bc8207b 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -847,6 +847,112 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { assert_eq!(tool.server_origin.as_deref(), Some("https://docs.example")); } +#[tokio::test] +async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let (tx_event, rx_event) = async_channel::unbounded(); + drop(rx_event); + let codex_home = tempdir().expect("tempdir"); + let mcp_servers = HashMap::from([ + ( + "stdio".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ( + "http".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ]); + + let (manager, cancel_token) = McpConnectionManager::new( + &mcp_servers, + OAuthCredentialsStoreMode::default(), + HashMap::new(), + &approval_policy, + String::new(), + tx_event, + PermissionProfile::default(), + McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ), + codex_home.path().to_path_buf(), + CodexAppsToolsCacheKey { + account_id: None, + chatgpt_user_id: None, + is_workspace_account: false, + }, + /*host_owned_codex_apps_enabled*/ false, + ElicitationCapability::default(), + ToolPluginProvenance::default(), + /*auth*/ None, + /*elicitation_reviewer*/ None, + ) + .await; + + assert!(manager.clients.contains_key("stdio")); + assert!(manager.clients.contains_key("http")); + assert!( + !manager + .wait_for_server_ready("stdio", Duration::from_millis(10)) + .await + ); + let failures = manager + .required_startup_failures(&["stdio".to_string()]) + .await; + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].server, "stdio"); + assert_eq!( + failures[0].error, + "local stdio MCP server `stdio` requires a local environment" + ); + cancel_token.cancel(); +} + #[test] fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() { let capability = Some(ElicitationCapability::default()); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index fcc9d1c86e..687576dfac 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -566,6 +566,9 @@ async fn make_rmcp_client( let config = match server.launch() { McpServerLaunch::Configured(config) => config.as_ref().clone(), }; + if let Some(reason) = runtime_environment.startup_unavailable_reason(server_name, &config) { + return Err(StartupOutcomeError::from(anyhow!(reason))); + } let McpServerConfig { transport, experimental_environment, @@ -573,14 +576,7 @@ async fn make_rmcp_client( } = config; let remote_environment = match experimental_environment.as_deref() { None | Some("local") => false, - Some("remote") => { - if !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote environment" - ))); - } - true - } + Some("remote") => true, Some(environment) => { return Err(StartupOutcomeError::from(anyhow!( "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" @@ -604,8 +600,15 @@ async fn make_rmcp_client( .collect::>() }); let launcher = if remote_environment { + // Preflight should reject this first, but keep client startup + // defensive if optional runtime placement is mis-threaded. + let Some(environment) = runtime_environment.environment() else { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server requires a runtime environment" + ))); + }; Arc::new(ExecutorStdioServerLauncher::new( - runtime_environment.environment().get_exec_backend(), + environment.get_exec_backend(), runtime_environment.fallback_cwd(), )) } else { @@ -628,7 +631,12 @@ async fn make_rmcp_client( bearer_token_env_var, } => { let http_client: Arc = if remote_environment { - runtime_environment.environment().get_http_client() + let Some(environment) = runtime_environment.environment() else { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server requires a runtime environment" + ))); + }; + environment.get_http_client() } else { Arc::new(ReqwestHttpClient) }; diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 4284c96ff6..dc1fcec219 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -30,33 +30,79 @@ pub struct SandboxState { /// Runtime placement information used when starting MCP server transports. /// -/// `McpConfig` describes what servers exist. This value describes where those -/// servers should run for the current caller. Keep it explicit at manager -/// construction time so status/snapshot paths and real sessions make the same -/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is -/// used when a stdio server omits `cwd` and the launcher needs a concrete -/// process working directory. +/// `McpConfig` describes what servers exist. This value describes which +/// selected/default environment MCP servers should use for the current caller. +/// Keep it explicit at manager construction time so status/snapshot paths and +/// real sessions make the same placement decision. `fallback_cwd` is not a +/// per-server override; it is used when a stdio server omits `cwd` and the +/// launcher needs a concrete process working directory. `local_environment` +/// is separate because a remote selected/default environment can coexist with +/// an explicitly configured local environment that may launch local stdio MCPs. #[derive(Clone)] pub struct McpRuntimeEnvironment { - environment: Arc, + environment: Option>, + local_environment: Option>, fallback_cwd: PathBuf, } impl McpRuntimeEnvironment { - pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { + pub fn new( + environment: Option>, + local_environment: Option>, + fallback_cwd: PathBuf, + ) -> Self { Self { environment, + local_environment, fallback_cwd, } } - pub(crate) fn environment(&self) -> Arc { - Arc::clone(&self.environment) + pub(crate) fn environment(&self) -> Option> { + self.environment.as_ref().map(Arc::clone) } pub(crate) fn fallback_cwd(&self) -> PathBuf { self.fallback_cwd.clone() } + + pub(crate) fn startup_unavailable_reason( + &self, + server_name: &str, + config: &codex_config::McpServerConfig, + ) -> Option { + // This is intentionally narrower than "no env means no MCP": local + // stdio needs a local process launcher, while local HTTP can still use + // the ambient HTTP client with no local environment configured. + match config.experimental_environment.as_deref() { + None | Some("local") => { + // Local stdio only needs an explicitly configured local + // launcher. The selected/default MCP environment can be remote + // when both local and remote environments are configured. + if self.local_environment.is_none() + && matches!( + config.transport, + codex_config::McpServerTransportConfig::Stdio { .. } + ) + { + Some(format!( + "local stdio MCP server `{server_name}` requires a local environment" + )) + } else { + None + } + } + Some("remote") => match self.environment.as_ref() { + Some(environment) if environment.is_remote() => None, + _ => Some(format!( + "remote MCP server `{server_name}` requires a remote environment" + )), + }, + Some(environment) => Some(format!( + "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" + )), + } + } } pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { @@ -64,3 +110,156 @@ pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &st let _ = metrics.record_duration(metric, duration, tags); } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use codex_config::McpServerConfig; + use codex_config::McpServerTransportConfig; + use pretty_assertions::assert_eq; + + use super::*; + + fn stdio_server(experimental_environment: Option<&str>) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: experimental_environment.map(str::to_string), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + } + } + + fn http_server(experimental_environment: Option<&str>) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: experimental_environment.map(str::to_string), + ..stdio_server(/*experimental_environment*/ None) + } + } + + #[test] + fn local_stdio_requires_local_stdio_availability() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ None) + ), + Some("local stdio MCP server `stdio` requires a local environment".to_string()) + ); + } + + #[test] + fn local_http_does_not_require_local_stdio_availability() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "http", + &http_server(/*experimental_environment*/ None) + ), + None + ); + } + + #[test] + fn remote_stdio_requires_remote_environment() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ Some("remote")), + ), + Some("remote MCP server `stdio` requires a remote environment".to_string()) + ); + } + + #[test] + fn remote_stdio_and_http_accept_remote_environment() { + let environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + ); + let runtime_environment = McpRuntimeEnvironment::new( + Some(environment), + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ Some("remote")), + ), + None + ); + assert_eq!( + runtime_environment.startup_unavailable_reason( + "http", + &http_server(/*experimental_environment*/ Some("remote")), + ), + None + ); + } + + #[tokio::test] + async fn local_stdio_accepts_remote_runtime_when_local_environment_exists() { + let remote_environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + ); + let local_environment = Arc::new( + Environment::create_for_tests(/*exec_server_url*/ None).expect("local environment"), + ); + let runtime_environment = McpRuntimeEnvironment::new( + Some(remote_environment), + Some(local_environment), + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ None), + ), + None + ); + } +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f4ee42c821..de41919a89 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -198,7 +198,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?; + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, @@ -261,10 +262,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let (tx_event, rx_event) = unbounded(); drop(rx_event); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -273,7 +270,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( INITIAL_SUBMIT_ID.to_owned(), tx_event, PermissionProfile::default(), - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), + // Connector discovery is threadless. Use an actually configured env if + // one exists, but do not reintroduce the old hidden-local fallback. + McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), host_owned_codex_apps_enabled, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 89808c27ee..749212f70a 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -106,7 +106,7 @@ mod tests { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); let manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -132,9 +132,10 @@ url = "ws://127.0.0.1:8765" ) .expect("write environments.toml"); let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths()) - .await - .expect("environment manager"); + let manager = + EnvironmentManager::from_codex_home(temp_dir.path(), Some(test_runtime_paths())) + .await + .expect("environment manager"); assert_eq!( default_thread_environment_selections(&manager, &cwd), @@ -154,7 +155,7 @@ url = "ws://127.0.0.1:8765" #[tokio::test] async fn default_thread_environment_selections_empty_when_default_disabled() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); assert_eq!( default_thread_environment_selections(&manager, &cwd), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 25f19c87dd..50a6bb4d45 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1235,8 +1235,8 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: let environment = session .services .environment_manager - .default_environment() - .unwrap_or_else(|| session.services.environment_manager.local_environment()); + .default_or_local_environment() + .expect("test session should have an MCP runtime environment"); let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new( &HashMap::new(), turn_context.config.mcp_oauth_credentials_store_mode, @@ -1245,10 +1245,14 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: turn_context.sub_id.clone(), session.get_tx_event(), turn_context.permission_profile(), - codex_mcp::McpRuntimeEnvironment::new(environment, { - #[allow(deprecated)] - turn_context.cwd.to_path_buf() - }), + codex_mcp::McpRuntimeEnvironment::new( + Some(environment), + session.services.environment_manager.try_local_environment(), + { + #[allow(deprecated)] + turn_context.cwd.to_path_buf() + }, + ), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), /*host_owned_codex_apps_enabled*/ true, diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index c6386d7c77..f6bf7d0022 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -45,9 +45,12 @@ pub async fn build_prompt_input( Arc::clone(&auth_manager), SessionSource::Exec, Arc::new( - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths) - .await - .map_err(|err| CodexErr::Fatal(err.to_string()))?, + EnvironmentManager::from_codex_home( + config.codex_home.clone(), + Some(local_runtime_paths), + ) + .await + .map_err(|err| CodexErr::Fatal(err.to_string()))?, ), empty_extension_registry(), /*analytics_events_client*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 9f27751f7e..47201414de 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -291,14 +291,15 @@ impl Session { compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; let mcp_runtime_environment = match turn_context.environments.primary() { Some(turn_environment) => McpRuntimeEnvironment::new( - Arc::clone(&turn_environment.environment), + Some(Arc::clone(&turn_environment.environment)), + self.services.environment_manager.try_local_environment(), turn_environment.cwd.to_path_buf(), ), None => McpRuntimeEnvironment::new( self.services .environment_manager - .default_environment() - .unwrap_or_else(|| self.services.environment_manager.local_environment()), + .default_or_local_environment(), + self.services.environment_manager.try_local_environment(), #[allow(deprecated)] turn_context.cwd.to_path_buf(), ), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2d96cf9165..884c440f28 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1050,14 +1050,13 @@ impl Session { .cloned(); let mcp_runtime_environment = match turn_environment { Some(turn_environment) => McpRuntimeEnvironment::new( - Arc::clone(&turn_environment.environment), + Some(Arc::clone(&turn_environment.environment)), + sess.services.environment_manager.try_local_environment(), turn_environment.cwd.to_path_buf(), ), None => McpRuntimeEnvironment::new( - sess.services - .environment_manager - .default_environment() - .unwrap_or_else(|| sess.services.environment_manager.local_environment()), + sess.services.environment_manager.default_or_local_environment(), + sess.services.environment_manager.try_local_environment(), session_configuration.cwd.to_path_buf(), ), }; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 43ec706f1a..255a8336b9 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -309,7 +309,7 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i let environment_manager = Arc::new( codex_exec_server::EnvironmentManager::create_for_tests( Some("none".to_string()), - runtime_paths, + Some(runtime_paths), ) .await, ); @@ -373,7 +373,7 @@ args = ["dev", "cd /tmp && true"] let environment_manager = Arc::new( codex_exec_server::EnvironmentManager::from_codex_home( config.codex_home.clone(), - runtime_paths, + Some(runtime_paths), ) .await .expect("environment manager"), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a1ba7b8fb2..5f5c7ee85a 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -431,7 +431,7 @@ impl TestCodexBuilder { } else { codex_exec_server::EnvironmentManager::create_for_tests( exec_server_url, - local_runtime_paths, + Some(local_runtime_paths), ) .await }); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 1d7d6e75f5..55dc031273 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -27,12 +27,11 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// /// `EnvironmentManager` is a shared registry for concrete environments. Its /// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior -/// while provider-based construction accepts a provider-supplied snapshot. +/// while configured construction accepts a provider-supplied snapshot. /// /// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving -/// the default environment unset while still keeping an explicit local -/// environment available through `local_environment()`. Callers use -/// `default_environment().is_some()` as the signal for model-facing +/// the default environment unset and omitting the local environment. Callers +/// use `default_environment().is_some()` as the signal for model-facing /// shell/filesystem tool availability. /// /// Remote environments create remote filesystem and execution backends that @@ -42,25 +41,13 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; pub struct EnvironmentManager { default_environment: Option, environments: RwLock>>, - local_environment: Arc, + local_environment: Option>, + local_runtime_paths: Option, } pub const LOCAL_ENVIRONMENT_ID: &str = "local"; pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; -#[derive(Clone, Debug)] -pub struct EnvironmentManagerArgs { - pub local_runtime_paths: ExecServerRuntimePaths, -} - -impl EnvironmentManagerArgs { - pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self { - Self { - local_runtime_paths, - } - } -} - impl EnvironmentManager { /// Builds a test-only manager without configured sandbox helper paths. pub fn default_for_tests() -> Self { @@ -70,37 +57,29 @@ impl EnvironmentManager { LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), )])), - local_environment: Arc::new(Environment::default_for_tests()), + local_environment: Some(Arc::new(Environment::default_for_tests())), + local_runtime_paths: None, } } - /// Builds a test-only manager with environment access disabled. - pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self { + /// Builds a manager with no configured execution environments. + pub fn without_environments() -> Self { Self { default_environment: None, environments: RwLock::new(HashMap::new()), - local_environment: Arc::new(Environment::local(local_runtime_paths)), + local_environment: None, + local_runtime_paths: None, } } /// Builds a test-only manager from a raw exec-server URL value. pub async fn create_for_tests( exec_server_url: Option, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Self { Self::from_default_provider_url(exec_server_url, local_runtime_paths).await } - /// Builds a manager from `CODEX_EXEC_SERVER_URL` and local runtime paths - /// used when creating local filesystem helpers. - pub async fn new(args: EnvironmentManagerArgs) -> Self { - let EnvironmentManagerArgs { - local_runtime_paths, - } = args; - let exec_server_url = std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(); - Self::from_default_provider_url(exec_server_url, local_runtime_paths).await - } - /// Builds a manager from `CODEX_HOME` and local runtime paths used when /// creating local filesystem helpers. /// @@ -109,27 +88,27 @@ impl EnvironmentManager { /// `CODEX_EXEC_SERVER_URL` behavior. pub async fn from_codex_home( codex_home: impl AsRef, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let provider = environment_provider_from_codex_home(codex_home.as_ref())?; - Self::from_provider(provider.as_ref(), local_runtime_paths).await + Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } /// Builds a manager from the legacy environment-variable provider without /// reading user config files from `CODEX_HOME`. pub async fn from_env( - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let provider = DefaultEnvironmentProvider::from_env(); - Self::from_provider(&provider, local_runtime_paths).await + Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } async fn from_default_provider_url( exec_server_url: Option, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Self { let provider = DefaultEnvironmentProvider::new(exec_server_url); - match Self::from_provider(&provider, local_runtime_paths).await { + match Self::from_snapshot(provider.snapshot_inner(), local_runtime_paths) { Ok(manager) => manager, Err(err) => panic!("default provider should create valid environments: {err}"), } @@ -143,26 +122,15 @@ impl EnvironmentManager { ) -> Self { let mut snapshot = DefaultEnvironmentProvider::new(exec_server_url).snapshot_inner(); snapshot.include_local = true; - match Self::from_provider_snapshot(snapshot, local_runtime_paths) { + match Self::from_snapshot(snapshot, Some(local_runtime_paths)) { Ok(manager) => manager, Err(err) => panic!("test provider with local should create valid environments: {err}"), } } - /// Builds a manager from a provider-supplied startup snapshot. - pub async fn from_provider

( - provider: &P, - local_runtime_paths: ExecServerRuntimePaths, - ) -> Result - where - P: EnvironmentProvider + ?Sized, - { - Self::from_provider_snapshot(provider.snapshot().await?, local_runtime_paths) - } - - fn from_provider_snapshot( + fn from_snapshot( snapshot: EnvironmentProviderSnapshot, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let EnvironmentProviderSnapshot { environments, @@ -171,13 +139,21 @@ impl EnvironmentManager { } = snapshot; let mut environment_map = HashMap::with_capacity(environments.len() + usize::from(include_local)); - let local_environment = Arc::new(Environment::local(local_runtime_paths)); - if include_local { + let local_environment = if include_local { + let local_runtime_paths = local_runtime_paths.clone().ok_or_else(|| { + ExecServerError::Protocol( + "local environment requires configured runtime paths".to_string(), + ) + })?; + let local_environment = Arc::new(Environment::local(local_runtime_paths)); environment_map.insert( LOCAL_ENVIRONMENT_ID.to_string(), Arc::clone(&local_environment), ); - } + Some(local_environment) + } else { + None + }; for (id, environment) in environments { if id.is_empty() { return Err(ExecServerError::Protocol( @@ -213,6 +189,7 @@ impl EnvironmentManager { default_environment, environments: RwLock::new(environment_map), local_environment, + local_runtime_paths, }) } @@ -248,9 +225,15 @@ impl EnvironmentManager { environment_ids } - /// Returns the local environment instance used for internal runtime work. - pub fn local_environment(&self) -> Arc { - Arc::clone(&self.local_environment) + /// Returns the local environment instance when one is configured. + pub fn try_local_environment(&self) -> Option> { + self.local_environment.as_ref().map(Arc::clone) + } + + /// Returns the default environment or local environment when either exists. + pub fn default_or_local_environment(&self) -> Option> { + self.default_environment() + .or_else(|| self.try_local_environment()) } /// Returns a named environment instance. @@ -285,10 +268,8 @@ impl EnvironmentManager { "remote environment requires an exec-server url".to_string(), )); }; - let environment = Environment::remote_inner( - exec_server_url, - self.local_environment.local_runtime_paths.clone(), - ); + let environment = + Environment::remote_inner(exec_server_url, self.local_runtime_paths.clone()); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -452,25 +433,12 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; - use crate::EnvironmentProvider; - use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; use pretty_assertions::assert_eq; - struct TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot, - } - - #[async_trait::async_trait] - impl EnvironmentProvider for TestEnvironmentProvider { - async fn snapshot(&self) -> Result { - Ok(self.snapshot.clone()) - } - } - fn test_runtime_paths() -> ExecServerRuntimePaths { ExecServerRuntimePaths::new( std::env::current_exe().expect("current exe"), @@ -479,6 +447,10 @@ mod tests { .expect("runtime paths") } + fn assert_local_environment_unavailable(manager: &EnvironmentManager) { + assert!(manager.try_local_environment().is_none()); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) @@ -491,7 +463,8 @@ mod tests { #[tokio::test] async fn environment_manager_normalizes_empty_url() { let manager = - EnvironmentManager::create_for_tests(Some(String::new()), test_runtime_paths()).await; + EnvironmentManager::create_for_tests(Some(String::new()), Some(test_runtime_paths())) + .await; let environment = manager.default_environment().expect("default environment"); assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID)); @@ -501,18 +474,22 @@ mod tests { .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") )); - assert!(Arc::ptr_eq(&environment, &manager.local_environment())); + assert!(Arc::ptr_eq( + &environment, + &manager.try_local_environment().expect("local environment") + )); + assert!(manager.try_local_environment().is_some()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); assert!(!environment.is_remote()); } #[tokio::test] - async fn disabled_environment_manager_has_no_default_but_keeps_explicit_local_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + async fn disabled_environment_manager_has_no_default_or_local_environment() { + let manager = EnvironmentManager::without_environments(); assert!(manager.default_environment().is_none()); assert_eq!(manager.default_environment_id(), None); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -521,7 +498,7 @@ mod tests { async fn environment_manager_reports_remote_url() { let manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -539,7 +516,7 @@ mod tests { .expect("remote environment") )); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); } #[tokio::test] @@ -557,20 +534,17 @@ mod tests { } #[tokio::test] - async fn environment_manager_builds_from_provider() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - REMOTE_ENVIRONMENT_ID.to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), - include_local: false, - }, + async fn environment_manager_builds_from_snapshot() { + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + REMOTE_ENVIRONMENT_ID.to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), + include_local: false, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("environment manager"); assert_eq!( @@ -584,20 +558,17 @@ mod tests { .is_remote() ); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); } #[tokio::test] async fn environment_manager_rejects_empty_environment_id() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![("".to_string(), Environment::default_for_tests())], - default: EnvironmentDefault::Disabled, - include_local: false, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![("".to_string(), Environment::default_for_tests())], + default: EnvironmentDefault::Disabled, + include_local: false, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("empty id should fail"); assert_eq!( @@ -608,18 +579,15 @@ mod tests { #[tokio::test] async fn environment_manager_rejects_provider_supplied_local_environment() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), - )], - default: EnvironmentDefault::Disabled, - include_local: false, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + LOCAL_ENVIRONMENT_ID.to_string(), + Environment::default_for_tests(), + )], + default: EnvironmentDefault::Disabled, + include_local: false, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("local id should fail"); assert_eq!( @@ -630,19 +598,16 @@ mod tests { #[tokio::test] async fn environment_manager_uses_explicit_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId("devbox".to_string()), - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId("devbox".to_string()), + include_local: true, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("manager"); assert_eq!(manager.default_environment_id(), Some("devbox")); @@ -655,19 +620,16 @@ mod tests { #[tokio::test] async fn environment_manager_disables_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::Disabled, - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::Disabled, + include_local: true, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("manager"); assert_eq!(manager.default_environment_id(), None); @@ -676,25 +638,22 @@ mod tests { &manager .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment"), - &manager.local_environment() + &manager.try_local_environment().expect("local environment") )); } #[tokio::test] async fn environment_manager_rejects_unknown_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId("missing".to_string()), - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId("missing".to_string()), + include_local: true, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("unknown default should fail"); assert_eq!( @@ -707,7 +666,7 @@ mod tests { async fn environment_manager_includes_local_for_default_provider_without_url() { let manager = EnvironmentManager::create_for_tests( /*exec_server_url*/ None, - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -719,7 +678,10 @@ mod tests { .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") )); - assert!(Arc::ptr_eq(&environment, &manager.local_environment())); + assert!(Arc::ptr_eq( + &environment, + &manager.try_local_environment().expect("local environment") + )); assert!(!environment.is_remote()); } @@ -728,44 +690,59 @@ mod tests { let runtime_paths = test_runtime_paths(); let manager = EnvironmentManager::create_for_tests( /*exec_server_url*/ None, - runtime_paths.clone(), + Some(runtime_paths.clone()), ) .await; - let environment = manager.local_environment(); + let environment = manager.try_local_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::create_for_tests( environment.exec_server_url().map(str::to_owned), - environment - .local_runtime_paths() - .expect("local runtime paths") - .clone(), + Some( + environment + .local_runtime_paths() + .expect("local runtime paths") + .clone(), + ), ) .await; - let environment = manager.local_environment(); + let environment = manager.try_local_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } - #[tokio::test] - async fn disabled_environment_manager_has_no_default_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); - - assert!(manager.default_environment().is_none()); - assert_eq!(manager.default_environment_id(), None); - } - #[tokio::test] async fn environment_manager_omits_default_provider_local_lookup_when_default_disabled() { - let manager = - EnvironmentManager::create_for_tests(Some("none".to_string()), test_runtime_paths()) - .await; + let manager = EnvironmentManager::create_for_tests( + Some("none".to_string()), + Some(test_runtime_paths()), + ) + .await; assert!(manager.default_environment().is_none()); assert_eq!(manager.default_environment_id(), None); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); + } + + #[tokio::test] + async fn environment_manager_snapshot_without_local_environment_disables_local_default() { + let mut snapshot = EnvironmentProviderSnapshot { + environments: Vec::new(), + default: EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()), + include_local: true, + }; + snapshot.include_local = false; + snapshot.default = EnvironmentDefault::Disabled; + let manager = + EnvironmentManager::from_snapshot(snapshot, /*local_runtime_paths*/ None) + .expect("environment manager"); + + assert!(manager.default_environment().is_none()); + assert_eq!(manager.default_environment_id(), None); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); + assert_local_environment_unavailable(&manager); } #[tokio::test] @@ -777,7 +754,7 @@ mod tests { #[tokio::test] async fn environment_manager_upserts_named_remote_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); manager .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:8765".to_string()) @@ -802,7 +779,7 @@ mod tests { #[tokio::test] async fn environment_manager_rejects_empty_remote_environment_url() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); let err = manager .upsert_environment("executor-a".to_string(), String::new()) diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 90f4c78262..26c178b5b8 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -27,6 +27,7 @@ const MAX_ENVIRONMENT_ID_LEN: usize = 64; #[serde(deny_unknown_fields)] struct EnvironmentsToml { default: Option, + include_local: Option, #[serde(default)] environments: Vec, @@ -50,6 +51,7 @@ struct EnvironmentToml { #[derive(Clone, Debug, PartialEq, Eq)] struct TomlEnvironmentProvider { default: EnvironmentDefault, + include_local: bool, environments: Vec<(String, ExecServerTransportParams)>, } @@ -63,21 +65,31 @@ impl TomlEnvironmentProvider { config: EnvironmentsToml, config_dir: Option<&Path>, ) -> Result { - let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]); - let mut environments = Vec::with_capacity(config.environments.len()); - for item in config.environments { + let EnvironmentsToml { + default, + include_local, + environments, + } = config; + let include_local = include_local.unwrap_or(true); + let mut ids = HashSet::new(); + if include_local { + ids.insert(LOCAL_ENVIRONMENT_ID.to_string()); + } + let mut parsed_environments = Vec::with_capacity(environments.len()); + for item in environments { let (id, transport) = parse_environment_toml(item, config_dir)?; if !ids.insert(id.clone()) { return Err(ExecServerError::Protocol(format!( "environment id `{id}` is duplicated" ))); } - environments.push((id, transport)); + parsed_environments.push((id, transport)); } - let default = normalize_default_environment_id(config.default.as_deref(), &ids)?; + let default = normalize_default_environment_id(default.as_deref(), include_local, &ids)?; Ok(Self { default, - environments, + include_local, + environments: parsed_environments, }) } } @@ -99,7 +111,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider { Ok(EnvironmentProviderSnapshot { environments, default: self.default.clone(), - include_local: true, + include_local: self.include_local, }) } } @@ -212,12 +224,17 @@ pub(crate) fn environment_provider_from_codex_home( fn normalize_default_environment_id( default: Option<&str>, + include_local: bool, ids: &HashSet, ) -> Result { let Some(default) = default.map(str::trim) else { - return Ok(EnvironmentDefault::EnvironmentId( - LOCAL_ENVIRONMENT_ID.to_string(), - )); + return if include_local { + Ok(EnvironmentDefault::EnvironmentId( + LOCAL_ENVIRONMENT_ID.to_string(), + )) + } else { + Ok(EnvironmentDefault::Disabled) + }; }; if default.is_empty() { return Err(ExecServerError::Protocol( @@ -330,6 +347,7 @@ mod tests { async fn toml_provider_includes_local_and_adds_configured_environments() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -396,6 +414,7 @@ mod tests { async fn toml_provider_default_none_disables_default() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("none".to_string()), + include_local: None, environments: Vec::new(), }) .expect("provider"); @@ -405,6 +424,55 @@ mod tests { assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } + #[tokio::test] + async fn toml_provider_can_disable_local_environment() { + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + default: Some("ssh-dev".to_string()), + include_local: Some(false), + environments: vec![EnvironmentToml { + id: "ssh-dev".to_string(), + program: Some("ssh".to_string()), + ..Default::default() + }], + }) + .expect("provider"); + let snapshot = provider.snapshot().await.expect("environments"); + + assert!(!snapshot.include_local); + assert_eq!( + snapshot.default, + EnvironmentDefault::EnvironmentId("ssh-dev".to_string()) + ); + } + + #[tokio::test] + async fn toml_provider_without_local_and_default_omitted_disables_default() { + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + include_local: Some(false), + ..Default::default() + }) + .expect("provider"); + let snapshot = provider.snapshot().await.expect("environments"); + + assert!(!snapshot.include_local); + assert_eq!(snapshot.default, EnvironmentDefault::Disabled); + } + + #[test] + fn toml_provider_rejects_local_default_when_local_is_disabled() { + let err = TomlEnvironmentProvider::new(EnvironmentsToml { + default: Some(LOCAL_ENVIRONMENT_ID.to_string()), + include_local: Some(false), + environments: Vec::new(), + }) + .expect_err("local default without local environment should fail"); + + assert_eq!( + err.to_string(), + "exec-server protocol error: default environment `local` is not configured" + ); + } + #[test] fn toml_provider_rejects_invalid_environments() { let cases = [ @@ -479,6 +547,7 @@ mod tests { for (item, expected) in cases { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![item], }) .expect_err("invalid item should fail"); @@ -496,6 +565,7 @@ mod tests { let provider = TomlEnvironmentProvider::new_with_config_dir( EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), @@ -525,6 +595,7 @@ mod tests { fn toml_provider_parses_configured_transport_timeouts() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -569,6 +640,7 @@ mod tests { fn toml_provider_rejects_relative_stdio_cwd_without_config_dir() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), @@ -588,6 +660,7 @@ mod tests { fn toml_provider_rejects_duplicate_ids() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -614,6 +687,7 @@ mod tests { let id = "a".repeat(MAX_ENVIRONMENT_ID_LEN + 1); let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: id.clone(), url: Some("ws://127.0.0.1:8765".to_string()), @@ -634,6 +708,7 @@ mod tests { fn toml_provider_rejects_unknown_default() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("missing".to_string()), + include_local: None, environments: Vec::new(), }) .expect_err("unknown default should fail"); @@ -652,6 +727,7 @@ mod tests { &path, r#" default = "ssh-dev" +include_local = false [[environments]] id = "devbox" @@ -673,6 +749,7 @@ CODEX_LOG = "debug" let environments = load_environments_toml(&path).expect("environments.toml"); assert_eq!(environments.default.as_deref(), Some("ssh-dev")); + assert_eq!(environments.include_local, Some(false)); assert_eq!(environments.environments.len(), 2); assert_eq!( environments.environments[0], @@ -736,6 +813,7 @@ unknown = true fn toml_provider_rejects_malformed_websocket_url() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "devbox".to_string(), url: Some("ws://".to_string()), @@ -758,6 +836,7 @@ unknown = true codex_home.path().join(ENVIRONMENTS_TOML_FILE), r#" default = "none" +include_local = false "#, ) .expect("write environments.toml"); @@ -772,7 +851,7 @@ default = "none" .map(|(id, _environment)| id) .collect(); - assert!(snapshot.include_local); + assert!(!snapshot.include_local); assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9e54a72abd..3db7a51576 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -522,9 +522,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result )?; let state_db = codex_core::init_state_db(&config).await; let environment_manager = if run_loader_overrides.ignore_user_config { - EnvironmentManager::from_env(local_runtime_paths).await? + EnvironmentManager::from_env(Some(local_runtime_paths)).await? } else { - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await? + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await? }; let in_process_start_args = InProcessClientStartArgs { arg0_paths, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index d82de48f9e..4a4ff054be 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -96,10 +96,10 @@ pub async fn run_main( let environment_manager = Arc::new( EnvironmentManager::from_codex_home( config.codex_home.clone(), - ExecServerRuntimePaths::from_optional_paths( + Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?, + )?), ) .await .map_err(std::io::Error::other)?, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index cf499c7703..9fdd6db90f 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -116,7 +116,8 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; let thread_store = thread_store_from_config(&config, state_db.clone()); let environment_manager = Arc::new( - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?, + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await?, ); let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9ece5e3776..4281ad2923 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -852,9 +852,9 @@ pub async fn run_main( )?; let environment_manager = if should_load_configured_environments(&loader_overrides, &app_server_target) { - EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await } else { - EnvironmentManager::from_env(local_runtime_paths).await + EnvironmentManager::from_env(Some(local_runtime_paths)).await } .map(Arc::new) .map_err(std::io::Error::other)?; @@ -2291,10 +2291,10 @@ mod tests { let target = AppServerTarget::Embedded; let environment_manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - ExecServerRuntimePaths::new( + Some(ExecServerRuntimePaths::new( std::env::current_exe().expect("current exe"), /*codex_linux_sandbox_exe*/ None, - )?, + )?), ) .await;