diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index ea4a19a8f5..200b23901f 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -132,9 +132,11 @@ runs: keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") fi + entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" + for binary in codex codex-responses-api-proxy; do path="codex-rs/target/${TARGET}/release/${binary}" - codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" + codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done - name: Notarize macOS binaries diff --git a/.github/actions/macos-code-sign/codex.entitlements.plist b/.github/actions/macos-code-sign/codex.entitlements.plist new file mode 100644 index 0000000000..d35e43ae58 --- /dev/null +++ b/.github/actions/macos-code-sign/codex.entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 716fcab0df..d0c6c390d5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -380,7 +380,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1802,6 +1802,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-code-mode" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "v8", +] + [[package]] name = "codex-config" version = "0.0.0" @@ -1859,6 +1873,7 @@ dependencies = [ "codex-arg0", "codex-artifacts", "codex-async-utils", + "codex-code-mode", "codex-config", "codex-connectors", "codex-exec-server", @@ -3826,7 +3841,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4071,7 +4086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5516,7 +5531,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6279,7 +6294,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6923,7 +6938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8345,7 +8360,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9778,7 +9793,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11224,7 +11239,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 43438a0575..e0c7633733 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "feedback", "features", "codex-backend-openapi-models", + "code-mode", "cloud-requirements", "cloud-tasks", "cloud-tasks-client", @@ -92,6 +93,7 @@ app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } +codex-code-mode = { path = "code-mode" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } @@ -376,7 +378,7 @@ ignored = [ "openssl-sys", "codex-utils-readiness", "codex-secrets", - "codex-v8-poc" + "codex-v8-poc", ] [profile.release] diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ae8e6fed34..903b26b80b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2343,13 +2343,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5b06ab539c..b576581cf9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -514,6 +514,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -1158,6 +1180,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -1623,6 +1646,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 c24c8ac249..a644549325 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 @@ -5658,6 +5658,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/v2/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -7972,6 +7994,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -9115,6 +9138,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, @@ -11323,6 +11353,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -11348,6 +11381,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -11404,13 +11438,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "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 c479da94e4..71e6034529 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 @@ -2251,6 +2251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -4676,6 +4698,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -5863,6 +5886,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, @@ -9083,6 +9113,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -9108,6 +9141,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -9164,13 +9198,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 264a901c0f..032743d55d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -112,9 +112,38 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "TurnError": { "properties": { "additionalDetails": { 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 84fea949c8..881c343601 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" 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 7b55420da2..18fdb5008d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" 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 5fecf50376..1917935a2e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -318,6 +318,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -343,6 +346,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], 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 98b485b578..2e0c3605e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json index 3fa74811d5..696226a50c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json @@ -1,16 +1,36 @@ { "$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": { "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" 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 7740421917..5ae0c5a122 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 55f02fedbc..126d78603a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 300f8d1f30..dfdab228df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 6c6597a660..8f48dee4b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 35a41983a4..edccc337da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 35e03397b0..cc41aac27f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 568c654561..c3b50fee3b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 971233fcde..2240150394 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 94046cd18d..41b0d2d409 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 b0220247aa..770cc920cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 cd9f63bb6c..7f1c3e4948 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", 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 3cc16db922..761ddc9a62 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 1ff409a41a..20dc3c5191 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.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 { NonSteerableTurnKind } from "./NonSteerableTurnKind"; /** * This translation layer make sure that we expose codex error code in camel case. @@ -8,4 +9,4 @@ * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; 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 a531b78dcf..b75ee3930a 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 = "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts new file mode 100644 index 0000000000..2624df2ba0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NonSteerableTurnKind = "review" | "compact"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts index 818e0b05d4..ea37393536 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.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 { SkillInterface } from "./SkillInterface"; -export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, }; +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts index 5a4bcf9bc0..273d593cfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts @@ -1,5 +1,14 @@ // 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 SkillsConfigWriteParams = { path: string, enabled: boolean, }; +export type SkillsConfigWriteParams = { +/** + * Path-based selector. + */ +path?: AbsolutePathBuf | null, +/** + * Name-based selector. + */ +name?: string | null, 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 d9cc4758bc..c649aec06a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; 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 11dfe29769..48fa56d687 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, + current_rollout_index: usize, + next_rollout_index: usize, } impl Default for ThreadHistoryBuilder { @@ -88,6 +90,8 @@ impl ThreadHistoryBuilder { turns: Vec::new(), current_turn: None, next_item_index: 1, + current_rollout_index: 0, + next_rollout_index: 0, } } @@ -111,6 +115,19 @@ impl ThreadHistoryBuilder { self.current_turn.is_some() } + pub fn active_turn_id_if_explicit(&self) -> Option { + self.current_turn + .as_ref() + .filter(|turn| turn.opened_explicitly) + .map(|turn| turn.id.clone()) + } + + pub fn active_turn_start_index(&self) -> Option { + self.current_turn + .as_ref() + .map(|turn| turn.rollout_start_index) + } + /// Shared reducer for persisted rollout replay and in-memory current-turn /// tracking used by running thread resume/rejoin. /// @@ -182,6 +199,8 @@ impl ThreadHistoryBuilder { } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { + self.current_rollout_index = self.next_rollout_index; + self.next_rollout_index += 1; match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), @@ -974,6 +993,7 @@ impl ThreadHistoryBuilder { status: TurnStatus::Completed, opened_explicitly: false, saw_compaction: false, + rollout_start_index: self.current_rollout_index, } } @@ -1137,6 +1157,8 @@ struct PendingTurn { /// True when this turn includes a persisted `RolloutItem::Compacted`, which /// should keep the turn from being dropped even without normal items. saw_compaction: bool, + /// Index of the rollout item that opened this turn during replay. + rollout_start_index: usize, } impl PendingTurn { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 57017833a6..3e30f81323 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; @@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core { }; } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// This translation layer make sure that we expose codex error code in camel case. /// /// When an upstream HTTP status is available (for example, from the Responses API or a provider), @@ -167,6 +176,13 @@ pub enum CodexErrorInfo { #[ts(rename = "httpStatusCode")] http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, Other, } @@ -193,11 +209,25 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } CoreCodexErrorInfo::Other => CodexErrorInfo::Other, } } } +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, )] @@ -347,7 +377,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - SessionStart, UserPromptSubmit, Stop + PreToolUse, SessionStart, UserPromptSubmit, Stop } ); @@ -3310,6 +3340,7 @@ pub struct SkillSummary { pub short_description: Option, pub interface: Option, pub path: PathBuf, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3348,7 +3379,12 @@ pub enum PluginSource { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct SkillsConfigWriteParams { - pub path: PathBuf, + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, pub enabled: bool, } @@ -7832,6 +7868,22 @@ mod tests { ); } + #[test] + fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); + } + #[test] fn dynamic_tool_response_serializes_content_items() { let value = serde_json::to_value(DynamicToolCallResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3248a44424..a536e5c68b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -125,7 +125,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. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; 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. +- `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. 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. - `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. @@ -140,7 +140,7 @@ Example with notification opt-out: - `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. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". -- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input. +- `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"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. @@ -164,10 +164,10 @@ Example with notification opt-out: - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). 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`). - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata 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. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**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/apps/MCP server names. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**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/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. -- `skills/config/write` — write user-level skill config by path. +- `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 plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -240,7 +240,7 @@ Example: { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } @@ -575,8 +575,8 @@ 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 turn. This does not emit -`turn/started` and does not accept turn context overrides. +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. ```json { "method": "turn/steer", "id": 32, "params": { @@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T { "id": 32, "result": { "turnId": "turn_456" } } ``` -`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error. +`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the +active turn, or the active turn kind does not accept same-turn steering (for example review or +manual compaction), the request fails with an `invalid request` error. ### Example: Request a code review @@ -918,6 +920,8 @@ There are additional item-specific events: - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream - `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion - `ResponseTooManyFailedAttempts { httpStatusCode? }` +- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the + current active turn was not steerable, for example `/review` or manual `/compact` - `BadRequest` - `Unauthorized` - `SandboxError` @@ -1141,14 +1145,29 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` -To enable or disable a skill by path: +To enable or disable a skill by absolute path: ```json { "method": "skills/config/write", "id": 26, "params": { - "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", + "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", + "name": null, + "enabled": false + } +} +``` + +To enable or disable a skill by name: + +```json +{ + "method": "skills/config/write", + "id": 27, + "params": { + "path": null, + "name": "github:yeet", "enabled": false } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1b02e4bb6c..37ff0ca4f2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -181,6 +183,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -4037,7 +4040,7 @@ impl CodexMessageProcessor { } = match self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path.clone(), persist_extended_history, @@ -5670,7 +5673,7 @@ impl CodexMessageProcessor { interface: outcome.plugin.interface.map(plugin_interface_to_info), }, description: outcome.plugin.description, - skills: plugin_skills_to_info(&visible_skills), + skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, }; @@ -5685,8 +5688,30 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: SkillsConfigWriteParams, ) { - let SkillsConfigWriteParams { path, enabled } = params; - let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; + let SkillsConfigWriteParams { + path, + name, + enabled, + } = params; + let edit = match (path, name) { + (Some(path), None) => ConfigEdit::SetSkillConfig { + path: path.into_path_buf(), + enabled, + }, + (None, Some(name)) if !name.trim().is_empty() => { + ConfigEdit::SetSkillConfigByName { name, enabled } + } + _ => { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "skills/config/write requires exactly one of path or name".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let edits = vec![edit]; let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() @@ -5694,6 +5719,7 @@ impl CodexMessageProcessor { match result { Ok(()) => { + self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); self.outgoing .send_response( @@ -6131,24 +6157,57 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message) = match err { + let (code, message, data) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), + None, ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), + None, ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let message = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => { + "cannot steer a review turn".to_string() + } + codex_protocol::protocol::NonSteerableTurnKind::Compact => { + "cannot steer a compact turn".to_string() + } + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some( + AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + ), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + (INVALID_REQUEST_ERROR_CODE, message, data) + } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), + None, ), }; let error = JSONRPCErrorError { code, message, - data: None, + data, }; self.outgoing.send_error(request_id, error).await; } @@ -6450,7 +6509,7 @@ impl CodexMessageProcessor { } = self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path, /*persist_extended_history*/ false, @@ -7634,7 +7693,10 @@ fn skills_to_info( .collect() } -fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec { +fn plugin_skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_skill_paths: &std::collections::HashSet, +) -> Vec { skills .iter() .map(|skill| SkillSummary { @@ -7652,6 +7714,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec Result<()> { "expected forked thread to include one turn" ); let turn = &thread.turns[0]; - assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.status, TurnStatus::Interrupted); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..e210783a35 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - format!("{path_element}{PATH_SEPARATOR}{existing_path}") - } - Err(_) => { - format!("{path_element}") + let updated_path_env_var = match std::env::var_os("PATH") { + Some(existing_path) => { + let mut path_env_var = + std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); + path_env_var.push(path); + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + path_env_var } + None => path.as_os_str().to_owned(), }; unsafe { diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index e37a85dc1d..fb0f62a342 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -883,6 +883,24 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) + } + + fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, ) -> serde_json::Value { chatgpt_auth_json_with_mode( plan_type, @@ -890,6 +908,7 @@ mod tests { account_id, access_token, refresh_token, + last_refresh, None, ) } @@ -900,6 +919,7 @@ mod tests { account_id: Option<&str>, access_token: &str, refresh_token: &str, + last_refresh: &str, auth_mode: Option<&str>, ) -> serde_json::Value { let header = json!({ "alg": "none", "typ": "JWT" }); @@ -925,7 +945,7 @@ mod tests { "refresh_token": refresh_token, "account_id": account_id, }, - "last_refresh": "2025-01-01T00:00:00Z", + "last_refresh": last_refresh, }); if let Some(auth_mode) = auth_mode { auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); @@ -1262,24 +1282,43 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-12345"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1314,24 +1353,41 @@ enabled = false #[tokio::test] async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth = managed_auth_context( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ); + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), - chatgpt_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new(AuthManager::new( + auth_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( "business", Some("user-99999"), Some("account-12345"), "fresh-access-token", "test-refresh-token", + "3025-01-01T00:00:00Z", ), ) .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), @@ -1432,6 +1488,7 @@ enabled = false Some("account-12345"), "test-access-token", "test-refresh-token", + "2025-01-01T00:00:00Z", Some("chatgptAuthTokens"), ), ) diff --git a/codex-rs/code-mode/BUILD.bazel b/codex-rs/code-mode/BUILD.bazel new file mode 100644 index 0000000000..bf39d9d5a5 --- /dev/null +++ b/codex-rs/code-mode/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode", + crate_name = "codex_code_mode", +) diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml new file mode 100644 index 0000000000..e821ca0e4d --- /dev/null +++ b/codex-rs/code-mode/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs new file mode 100644 index 0000000000..c875e2a1b1 --- /dev/null +++ b/codex-rs/code-mode/src/description.rs @@ -0,0 +1,555 @@ +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use crate::PUBLIC_TOOL_NAME; + +const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; +const CODE_MODE_ONLY_PREFACE: &str = + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec +- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). +- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. +- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. +- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- Tool methods take either string or object as parameter. +- They return either a structured value or a string based on the description above. + +- 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 })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. +- `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(...)`. +- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. +- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; +const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. +- `cell_id` identifies the running `exec` cell to resume. +- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. +- `max_tokens` limits how much new output this wait call returns. +- `terminate: true` stops the running cell instead of waiting for more output. +- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. +- If the cell is still running, `wait` may yield again with the same `cell_id`. +- If the cell has already finished, `wait` returns the completed result and closes the cell."#; + +pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodeModeToolKind { + Function, + Freeform, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub kind: CodeModeToolKind, + pub input_schema: Option, + pub output_schema: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CodeModeExecPragma { + #[serde(default)] + yield_time_ms: Option, + #[serde(default)] + max_output_tokens: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedExecSource { + pub code: String, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +pub fn parse_exec_source(input: &str) -> Result { + if input.trim().is_empty() { + return Err( + "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), + ); + } + + let mut args = ParsedExecSource { + code: input.to_string(), + yield_time_ms: None, + max_output_tokens: None, + }; + + let mut lines = input.splitn(2, '\n'); + let first_line = lines.next().unwrap_or_default(); + let rest = lines.next().unwrap_or_default(); + let trimmed = first_line.trim_start(); + let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + return Ok(args); + }; + + if rest.trim().is_empty() { + return Err( + "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), + ); + } + + let directive = pragma.trim(); + if directive.is_empty() { + return Err( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + ); + } + + let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { + format!( + "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" + ) + })?; + let object = value.as_object().ok_or_else(|| { + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string() + })?; + for key in object.keys() { + match key.as_str() { + "yield_time_ms" | "max_output_tokens" => {} + _ => { + return Err(format!( + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" + )); + } + } + } + + let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { + format!( + "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" + ) + })?; + if pragma + .yield_time_ms + .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) + { + return Err( + "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), + ); + } + if pragma.max_output_tokens.is_some_and(|max_output_tokens| { + u64::try_from(max_output_tokens) + .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) + .unwrap_or(true) + }) { + return Err( + "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), + ); + } + + args.code = rest.to_string(); + args.yield_time_ms = pragma.yield_time_ms; + args.max_output_tokens = pragma.max_output_tokens; + Ok(args) +} + +pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { + tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME +} + +pub fn build_exec_tool_description( + enabled_tools: &[(String, String)], + code_mode_only: bool, +) -> String { + if !code_mode_only { + return EXEC_DESCRIPTION_TEMPLATE.to_string(); + } + + let mut sections = vec![ + CODE_MODE_ONLY_PREFACE.to_string(), + EXEC_DESCRIPTION_TEMPLATE.to_string(), + ]; + + if !enabled_tools.is_empty() { + let nested_tool_reference = enabled_tools + .iter() + .map(|(name, nested_description)| { + let global_name = normalize_code_mode_identifier(name); + format!( + "### `{global_name}` (`{name}`)\n{}", + nested_description.trim() + ) + }) + .collect::>() + .join("\n\n"); + sections.push(nested_tool_reference); + } + + sections.join("\n\n") +} + +pub fn build_wait_tool_description() -> &'static str { + WAIT_DESCRIPTION_TEMPLATE +} + +pub fn normalize_code_mode_identifier(tool_key: &str) -> String { + let mut identifier = String::new(); + + for (index, ch) in tool_key.chars().enumerate() { + let is_valid = if index == 0 { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() + } else { + ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() + }; + + if is_valid { + identifier.push(ch); + } else { + identifier.push('_'); + } + } + + if identifier.is_empty() { + "_".to_string() + } else { + identifier + } +} + +pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition { + if definition.name != PUBLIC_TOOL_NAME { + definition.description = append_code_mode_sample_for_definition(&definition); + } + definition +} + +pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata { + EnabledToolMetadata { + tool_name: definition.name.clone(), + global_name: normalize_code_mode_identifier(&definition.name), + description: definition.description.clone(), + kind: definition.kind, + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct EnabledToolMetadata { + pub tool_name: String, + pub global_name: String, + pub description: String, + pub kind: CodeModeToolKind, +} + +pub fn append_code_mode_sample( + description: &str, + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let declaration = format!( + "declare const tools: {{ {} }};", + render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) + ); + format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") +} + +fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { + let input_name = match definition.kind { + CodeModeToolKind::Function => "args", + CodeModeToolKind::Freeform => "input", + }; + let input_type = match definition.kind { + CodeModeToolKind::Function => definition + .input_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()), + CodeModeToolKind::Freeform => "string".to_string(), + }; + let output_type = definition + .output_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + append_code_mode_sample( + &definition.description, + &definition.name, + input_name, + input_type, + output_type, + ) +} + +fn render_code_mode_tool_declaration( + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let tool_name = normalize_code_mode_identifier(tool_name); + format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") +} + +pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { + render_json_schema_to_typescript_inner(schema) +} + +fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { + match schema { + JsonValue::Bool(true) => "unknown".to_string(), + JsonValue::Bool(false) => "never".to_string(), + JsonValue::Object(map) => { + if let Some(value) = map.get("const") { + return render_json_schema_literal(value); + } + + if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { + let rendered = values + .iter() + .map(render_json_schema_literal) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + } + + if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" & "); + } + } + + if let Some(schema_type) = map.get("type") { + if let Some(types) = schema_type.as_array() { + let rendered = types + .iter() + .filter_map(JsonValue::as_str) + .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + if let Some(schema_type) = schema_type.as_str() { + return render_json_schema_type_keyword(map, schema_type); + } + } + + if map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("required") + { + return render_json_schema_object(map); + } + + if map.contains_key("items") || map.contains_key("prefixItems") { + return render_json_schema_array(map); + } + + "unknown".to_string() + } + _ => "unknown".to_string(), + } +} + +fn render_json_schema_type_keyword( + map: &serde_json::Map, + schema_type: &str, +) -> String { + match schema_type { + "string" => "string".to_string(), + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_json_schema_array(map), + "object" => render_json_schema_object(map), + _ => "unknown".to_string(), + } +} + +fn render_json_schema_array(map: &serde_json::Map) -> String { + if let Some(items) = map.get("items") { + let item_type = render_json_schema_to_typescript_inner(items); + return format!("Array<{item_type}>"); + } + + if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { + let item_types = items + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !item_types.is_empty() { + return format!("[{}]", item_types.join(", ")); + } + } + + "unknown[]".to_string() +} + +fn render_json_schema_object(map: &serde_json::Map) -> String { + let required = map + .get("required") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .collect::>() + }) + .unwrap_or_default(); + let properties = map + .get("properties") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + let mut sorted_properties = properties.iter().collect::>(); + sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + let mut lines = sorted_properties + .into_iter() + .map(|(name, value)| { + let optional = if required.iter().any(|required_name| required_name == name) { + "" + } else { + "?" + }; + let property_name = render_json_schema_property_name(name); + let property_type = render_json_schema_to_typescript_inner(value); + format!("{property_name}{optional}: {property_type};") + }) + .collect::>(); + + if let Some(additional_properties) = map.get("additionalProperties") { + let property_type = match additional_properties { + JsonValue::Bool(true) => Some("unknown".to_string()), + JsonValue::Bool(false) => None, + value => Some(render_json_schema_to_typescript_inner(value)), + }; + + if let Some(property_type) = property_type { + lines.push(format!("[key: string]: {property_type};")); + } + } else if properties.is_empty() { + lines.push("[key: string]: unknown;".to_string()); + } + + if lines.is_empty() { + return "{}".to_string(); + } + + format!("{{ {} }}", lines.join(" ")) +} + +fn render_json_schema_property_name(name: &str) -> String { + if normalize_code_mode_identifier(name) == name { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) + } +} + +fn render_json_schema_literal(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::CodeModeToolKind; + use super::ParsedExecSource; + use super::ToolDefinition; + use super::augment_tool_definition; + use super::build_exec_tool_description; + use super::normalize_code_mode_identifier; + use super::parse_exec_source; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn parse_exec_source_without_pragma() { + assert_eq!( + parse_exec_source("text('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_with_pragma() { + assert_eq!( + parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: Some(10), + max_output_tokens: None, + } + ); + } + + #[test] + fn normalize_identifier_rewrites_invalid_characters() { + assert_eq!( + "mcp__ologs__get_profile", + normalize_code_mode_identifier("mcp__ologs__get_profile") + ); + assert_eq!( + "hidden_dynamic_tool", + normalize_code_mode_identifier("hidden-dynamic-tool") + ); + } + + #[test] + fn augment_tool_definition_appends_typed_declaration() { + let definition = ToolDefinition { + name: "hidden_dynamic_tool".to_string(), + description: "Test tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + })), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains("declare const tools")); + assert!( + description.contains( + "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;" + ) + ); + } + + #[test] + fn code_mode_only_description_includes_nested_tools() { + let description = + build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true); + assert!(description.contains("### `foo` (`foo`)")); + } +} diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs new file mode 100644 index 0000000000..841e568be3 --- /dev/null +++ b/codex-rs/code-mode/src/lib.rs @@ -0,0 +1,30 @@ +mod description; +mod response; +mod runtime; +mod service; + +pub use description::CODE_MODE_PRAGMA_PREFIX; +pub use description::CodeModeToolKind; +pub use description::ToolDefinition; +pub use description::append_code_mode_sample; +pub use description::augment_tool_definition; +pub use description::build_exec_tool_description; +pub use description::build_wait_tool_description; +pub use description::is_code_mode_nested_tool; +pub use description::normalize_code_mode_identifier; +pub use description::parse_exec_source; +pub use description::render_json_schema_to_typescript; +pub use response::FunctionCallOutputContentItem; +pub use response::ImageDetail; +pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; +pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; +pub use runtime::ExecuteRequest; +pub use runtime::RuntimeResponse; +pub use runtime::WaitRequest; +pub use service::CodeModeService; +pub use service::CodeModeTurnHost; +pub use service::CodeModeTurnWorker; + +pub const PUBLIC_TOOL_NAME: &str = "exec"; +pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs new file mode 100644 index 0000000000..43579fac85 --- /dev/null +++ b/codex-rs/code-mode/src/response.rs @@ -0,0 +1,24 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail { + Auto, + Low, + High, + Original, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FunctionCallOutputContentItem { + InputText { + text: String, + }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs new file mode 100644 index 0000000000..b77ae82d68 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -0,0 +1,209 @@ +use crate::response::FunctionCallOutputContentItem; + +use super::EXIT_SENTINEL; +use super::RuntimeEvent; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::normalize_output_image; +use super::value::serialize_output_text; +use super::value::throw_type_error; +use super::value::v8_value_to_json; + +pub(super) fn tool_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let tool_name = args.data().to_rust_string_lossy(scope); + let input = if args.length() == 0 { + Ok(None) + } else { + v8_value_to_json(scope, args.get(0)) + }; + let input = match input { + Ok(input) => input, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + let Some(resolver) = v8::PromiseResolver::new(scope) else { + throw_type_error(scope, "failed to create tool promise"); + return; + }; + let promise = resolver.get_promise(scope); + + let resolver = v8::Global::new(scope, resolver); + let Some(state) = scope.get_slot_mut::() else { + throw_type_error(scope, "runtime state unavailable"); + return; + }; + let id = format!("tool-{}", state.next_tool_call_id); + state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); + let event_tx = state.event_tx.clone(); + state.pending_tool_calls.insert(id.clone(), resolver); + let _ = event_tx.send(RuntimeEvent::ToolCall { + id, + name: tool_name, + input, + }); + retval.set(promise.into()); +} + +pub(super) fn text_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let image_item = match normalize_output_image(scope, value) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn store_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "store key must be a string"); + return; + } + }; + let value = args.get(1); + let serialized = match v8_value_to_json(scope, value) { + Ok(Some(value)) => value, + Ok(None) => { + throw_type_error( + scope, + &format!("Unable to store {key:?}. Only plain serializable objects can be stored."), + ); + return; + } + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot_mut::() { + state.stored_values.insert(key, serialized); + } +} + +pub(super) fn load_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "load key must be a string"); + return; + } + }; + let value = scope + .get_slot::() + .and_then(|state| state.stored_values.get(&key)) + .cloned(); + let Some(value) = value else { + retval.set(v8::undefined(scope).into()); + return; + }; + let Some(value) = json_to_v8(scope, &value) else { + throw_type_error(scope, "failed to load stored value"); + return; + }; + retval.set(value); +} + +pub(super) fn notify_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if text.trim().is_empty() { + throw_type_error(scope, "notify expects non-empty text"); + return; + } + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::Notify { + call_id: state.tool_call_id.clone(), + text, + }); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn yield_control_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::YieldRequested); + } +} + +pub(super) fn exit_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot_mut::() { + state.exit_requested = true; + } + if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) { + scope.throw_exception(error.into()); + } +} diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs new file mode 100644 index 0000000000..371479497b --- /dev/null +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -0,0 +1,138 @@ +use super::RuntimeState; +use super::callbacks::exit_callback; +use super::callbacks::image_callback; +use super::callbacks::load_callback; +use super::callbacks::notify_callback; +use super::callbacks::store_callback; +use super::callbacks::text_callback; +use super::callbacks::tool_callback; +use super::callbacks::yield_control_callback; + +pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { + let global = scope.get_current_context().global(scope); + let console = v8::String::new(scope, "console") + .ok_or_else(|| "failed to allocate global `console`".to_string())?; + if global.delete(scope, console.into()) != Some(true) { + return Err("failed to remove global `console`".to_string()); + } + + let tools = build_tools_object(scope)?; + let all_tools = build_all_tools_value(scope)?; + let text = helper_function(scope, "text", text_callback)?; + let image = helper_function(scope, "image", image_callback)?; + let store = helper_function(scope, "store", store_callback)?; + let load = helper_function(scope, "load", load_callback)?; + let notify = helper_function(scope, "notify", notify_callback)?; + let yield_control = helper_function(scope, "yield_control", yield_control_callback)?; + let exit = helper_function(scope, "exit", exit_callback)?; + + set_global(scope, global, "tools", tools.into())?; + set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "text", text.into())?; + set_global(scope, global, "image", image.into())?; + set_global(scope, global, "store", store.into())?; + set_global(scope, global, "load", load.into())?; + set_global(scope, global, "notify", notify.into())?; + set_global(scope, global, "yield_control", yield_control.into())?; + set_global(scope, global, "exit", exit.into())?; + Ok(()) +} + +fn build_tools_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let tools = v8::Object::new(scope); + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + + for tool in enabled_tools { + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate tool name".to_string())?; + let function = tool_function(scope, &tool.tool_name)?; + tools.set(scope, name.into(), function.into()); + } + Ok(tools) +} + +fn build_all_tools_value<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + let array = v8::Array::new(scope, enabled_tools.len() as i32); + let name_key = v8::String::new(scope, "name") + .ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?; + let description_key = v8::String::new(scope, "description") + .ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?; + + for (index, tool) in enabled_tools.iter().enumerate() { + let item = v8::Object::new(scope); + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?; + let description = v8::String::new(scope, &tool.description) + .ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?; + + if item.set(scope, name_key.into(), name.into()) != Some(true) { + return Err("failed to set ALL_TOOLS name".to_string()); + } + if item.set(scope, description_key.into(), description.into()) != Some(true) { + return Err("failed to set ALL_TOOLS description".to_string()); + } + if array.set_index(scope, index as u32, item.into()) != Some(true) { + return Err("failed to append ALL_TOOLS metadata".to_string()); + } + } + + Ok(array.into()) +} + +fn helper_function<'s, F>( + scope: &mut v8::PinScope<'s, '_>, + name: &str, + callback: F, +) -> Result, String> +where + F: v8::MapFnTo, +{ + let name = + v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?; + let template = v8::FunctionTemplate::builder(callback) + .data(name.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create helper function".to_string()) +} + +fn tool_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + tool_name: &str, +) -> Result, String> { + let data = v8::String::new(scope, tool_name) + .ok_or_else(|| "failed to allocate tool callback data".to_string())?; + let template = v8::FunctionTemplate::builder(tool_callback) + .data(data.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create tool function".to_string()) +} + +fn set_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, + value: v8::Local<'s, v8::Value>, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.set(scope, key.into(), value) == Some(true) { + Ok(()) + } else { + Err(format!("failed to set global `{name}`")) + } +} diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs new file mode 100644 index 0000000000..df90eda673 --- /dev/null +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -0,0 +1,349 @@ +mod callbacks; +mod globals; +mod module_loader; +mod value; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::mpsc as std_mpsc; +use std::thread; + +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; + +use crate::description::EnabledToolMetadata; +use crate::description::ToolDefinition; +use crate::description::enabled_tool_metadata; +use crate::response::FunctionCallOutputContentItem; + +pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; +const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; + +#[derive(Clone, Debug)] +pub struct ExecuteRequest { + pub tool_call_id: String, + pub enabled_tools: Vec, + pub source: String, + pub stored_values: HashMap, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct WaitRequest { + pub cell_id: String, + pub yield_time_ms: u64, + pub terminate: bool, +} + +#[derive(Debug, PartialEq)] +pub enum RuntimeResponse { + Yielded { + cell_id: String, + content_items: Vec, + }, + Terminated { + cell_id: String, + content_items: Vec, + }, + Result { + cell_id: String, + content_items: Vec, + stored_values: HashMap, + error_text: Option, + }, +} + +#[derive(Debug)] +pub(crate) enum TurnMessage { + ToolCall { + cell_id: String, + id: String, + name: String, + input: Option, + }, + Notify { + cell_id: String, + call_id: String, + text: String, + }, +} + +#[derive(Debug)] +pub(crate) enum RuntimeCommand { + ToolResponse { id: String, result: JsonValue }, + ToolError { id: String, error_text: String }, + Terminate, +} + +#[derive(Debug)] +pub(crate) enum RuntimeEvent { + Started, + ContentItem(FunctionCallOutputContentItem), + YieldRequested, + ToolCall { + id: String, + name: String, + input: Option, + }, + Notify { + call_id: String, + text: String, + }, + Result { + stored_values: HashMap, + error_text: Option, + }, +} + +pub(crate) fn spawn_runtime( + request: ExecuteRequest, + event_tx: mpsc::UnboundedSender, +) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + let (command_tx, command_rx) = std_mpsc::channel(); + let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); + let enabled_tools = request + .enabled_tools + .iter() + .map(enabled_tool_metadata) + .collect::>(); + let config = RuntimeConfig { + tool_call_id: request.tool_call_id, + enabled_tools, + source: request.source, + stored_values: request.stored_values, + }; + + thread::spawn(move || { + run_runtime(config, event_tx, command_rx, isolate_handle_tx); + }); + + let isolate_handle = isolate_handle_rx + .recv() + .map_err(|_| "failed to initialize code mode runtime".to_string())?; + Ok((command_tx, isolate_handle)) +} + +#[derive(Clone)] +struct RuntimeConfig { + tool_call_id: String, + enabled_tools: Vec, + source: String, + stored_values: HashMap, +} + +pub(super) struct RuntimeState { + event_tx: mpsc::UnboundedSender, + pending_tool_calls: HashMap>, + stored_values: HashMap, + enabled_tools: Vec, + next_tool_call_id: u64, + tool_call_id: String, + exit_requested: bool, +} + +pub(super) enum CompletionState { + Pending, + Completed { + stored_values: HashMap, + error_text: Option, + }, +} + +fn initialize_v8() { + static PLATFORM: OnceLock> = OnceLock::new(); + + let _ = PLATFORM.get_or_init(|| { + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + platform + }); +} + +fn run_runtime( + config: RuntimeConfig, + event_tx: mpsc::UnboundedSender, + command_rx: std_mpsc::Receiver, + isolate_handle_tx: std_mpsc::SyncSender, +) { + initialize_v8(); + + let isolate = &mut v8::Isolate::new(v8::CreateParams::default()); + let isolate_handle = isolate.thread_safe_handle(); + if isolate_handle_tx.send(isolate_handle).is_err() { + return; + } + isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback); + + v8::scope!(let scope, isolate); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + + scope.set_slot(RuntimeState { + event_tx: event_tx.clone(), + pending_tool_calls: HashMap::new(), + stored_values: config.stored_values, + enabled_tools: config.enabled_tools, + next_tool_call_id: 1, + tool_call_id: config.tool_call_id, + exit_requested: false, + }); + + if let Err(error_text) = globals::install_globals(scope) { + send_result(&event_tx, HashMap::new(), Some(error_text)); + return; + } + + let _ = event_tx.send(RuntimeEvent::Started); + + let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) { + Ok(pending_promise) => pending_promise, + Err(error_text) => { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + }; + + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + let mut pending_promise = pending_promise; + loop { + let Ok(command) = command_rx.recv() else { + break; + }; + match command { + RuntimeCommand::Terminate => break, + RuntimeCommand::ToolResponse { id, result } => { + if let Err(error_text) = + module_loader::resolve_tool_response(scope, &id, Ok(result)) + { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + } + RuntimeCommand::ToolError { id, error_text } => { + if let Err(runtime_error) = + module_loader::resolve_tool_response(scope, &id, Err(error_text)) + { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } + } + + scope.perform_microtask_checkpoint(); + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + if let Some(promise) = pending_promise.as_ref() { + let promise = v8::Local::new(scope, promise); + if promise.state() != v8::PromiseState::Pending { + pending_promise = None; + } + } + } +} + +fn capture_scope_send_error( + scope: &mut v8::PinScope<'_, '_>, + event_tx: &mpsc::UnboundedSender, + error_text: Option, +) { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + send_result(event_tx, stored_values, error_text); +} + +fn send_result( + event_tx: &mpsc::UnboundedSender, + stored_values: HashMap, + error_text: Option, +) { + let _ = event_tx.send(RuntimeEvent::Result { + stored_values, + error_text, + }); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::ExecuteRequest; + use super::RuntimeEvent; + use super::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + #[tokio::test] + async fn terminate_execution_stops_cpu_bound_module() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (_runtime_tx, runtime_terminate_handle) = + spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + + let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(started_event, RuntimeEvent::Started)); + + assert!(runtime_terminate_handle.terminate_execution()); + + let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::Result { + stored_values, + error_text, + } = result_event + else { + panic!("expected runtime result after termination"); + }; + assert_eq!(stored_values, HashMap::new()); + assert!(error_text.is_some()); + + assert!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode/src/runtime/module_loader.rs new file mode 100644 index 0000000000..83ce3d347a --- /dev/null +++ b/codex-rs/code-mode/src/runtime/module_loader.rs @@ -0,0 +1,235 @@ +use serde_json::Value as JsonValue; + +use super::CompletionState; +use super::EXIT_SENTINEL; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::value_to_error_text; + +pub(super) fn evaluate_main_module( + scope: &mut v8::PinScope<'_, '_>, + source_text: &str, +) -> Result>, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let source = v8::String::new(&tc, source_text) + .ok_or_else(|| "failed to allocate exec source".to_string())?; + let origin = script_origin(&mut tc, "exec_main.mjs")?; + let mut source = v8::script_compiler::Source::new(source, Some(&origin)); + let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + module + .instantiate_module(&tc, resolve_module_callback) + .ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + let result = match module.evaluate(&tc) { + Some(result) => result, + None => { + if let Some(exception) = tc.exception() { + if is_exit_exception(&mut tc, exception) { + return Ok(None); + } + return Err(value_to_error_text(&mut tc, exception)); + } + return Err("unknown code mode exception".to_string()); + } + }; + tc.perform_microtask_checkpoint(); + + if result.is_promise() { + let promise = v8::Local::::try_from(result) + .map_err(|_| "failed to read exec promise".to_string())?; + return Ok(Some(v8::Global::new(&tc, promise))); + } + + Ok(None) +} + +fn is_exit_exception( + scope: &mut v8::PinScope<'_, '_>, + exception: v8::Local<'_, v8::Value>, +) -> bool { + scope + .get_slot::() + .map(|state| state.exit_requested) + .unwrap_or(false) + && exception.is_string() + && exception.to_rust_string_lossy(scope) == EXIT_SENTINEL +} + +pub(super) fn resolve_tool_response( + scope: &mut v8::PinScope<'_, '_>, + id: &str, + response: Result, +) -> Result<(), String> { + let resolver = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_tool_calls.remove(id) + } + .ok_or_else(|| format!("unknown tool call `{id}`"))?; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let resolver = v8::Local::new(&tc, &resolver); + match response { + Ok(result) => { + let value = json_to_v8(&mut tc, &result) + .ok_or_else(|| "failed to serialize tool response".to_string())?; + resolver.resolve(&tc, value); + } + Err(error_text) => { + let value = v8::String::new(&tc, &error_text) + .ok_or_else(|| "failed to allocate tool error".to_string())?; + resolver.reject(&tc, value.into()); + } + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(()) +} + +pub(super) fn completion_state( + scope: &mut v8::PinScope<'_, '_>, + pending_promise: Option<&v8::Global>, +) -> CompletionState { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + let Some(pending_promise) = pending_promise else { + return CompletionState::Completed { + stored_values, + error_text: None, + }; + }; + + let promise = v8::Local::new(scope, pending_promise); + match promise.state() { + v8::PromiseState::Pending => CompletionState::Pending, + v8::PromiseState::Fulfilled => CompletionState::Completed { + stored_values, + error_text: None, + }, + v8::PromiseState::Rejected => { + let result = promise.result(scope); + let error_text = if is_exit_exception(scope, result) { + None + } else { + Some(value_to_error_text(scope, result)) + }; + CompletionState::Completed { + stored_values, + error_text, + } + } + } +} + +fn script_origin<'s>( + scope: &mut v8::PinScope<'s, '_>, + resource_name_: &str, +) -> Result, String> { + let resource_name = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate script origin".to_string())?; + let source_map_url = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate source map url".to_string())?; + Ok(v8::ScriptOrigin::new( + scope, + resource_name.into(), + 0, + 0, + true, + 0, + Some(source_map_url.into()), + true, + false, + true, + None, + )) +} + +fn resolve_module_callback<'s>( + context: v8::Local<'s, v8::Context>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, + _referrer: v8::Local<'s, v8::Module>, +) -> Option> { + v8::callback_scope!(unsafe scope, context); + let specifier = specifier.to_rust_string_lossy(scope); + resolve_module(scope, &specifier) +} + +pub(super) fn dynamic_import_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + _host_defined_options: v8::Local<'s, v8::Data>, + _resource_name: v8::Local<'s, v8::Value>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let specifier = specifier.to_rust_string_lossy(scope); + let resolver = v8::PromiseResolver::new(scope)?; + + match resolve_module(scope, &specifier) { + Some(module) => { + if module.get_status() == v8::ModuleStatus::Uninstantiated + && module + .instantiate_module(scope, resolve_module_callback) + .is_none() + { + let error = v8::String::new(scope, "failed to instantiate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + if matches!( + module.get_status(), + v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated + ) && module.evaluate(scope).is_none() + { + let error = v8::String::new(scope, "failed to evaluate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + let namespace = module.get_module_namespace(); + resolver.resolve(scope, namespace); + Some(resolver.get_promise(scope)) + } + None => { + let error = v8::String::new(scope, "unsupported import in exec") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + Some(resolver.get_promise(scope)) + } + } +} + +fn resolve_module<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, +) -> Option> { + if let Some(message) = + v8::String::new(scope, &format!("Unsupported import in exec: {specifier}")) + { + scope.throw_exception(message.into()); + } else { + scope.throw_exception(v8::undefined(scope).into()); + } + None +} diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs new file mode 100644 index 0000000000..eb0280142c --- /dev/null +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -0,0 +1,163 @@ +use serde_json::Value as JsonValue; + +use crate::response::FunctionCallOutputContentItem; +use crate::response::ImageDetail; + +pub(super) fn serialize_output_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + if value.is_undefined() + || value.is_null() + || value.is_boolean() + || value.is_number() + || value.is_big_int() + || value.is_string() + { + return Ok(value.to_rust_string_lossy(scope)); + } + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + if let Some(stringified) = v8::json::stringify(&tc, value) { + return Ok(stringified.to_rust_string_lossy(&tc)); + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(value.to_rust_string_lossy(&tc)) +} + +pub(super) fn normalize_output_image( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + let result = (|| -> Result { + let (image_url, detail) = if value.is_string() { + (value.to_rust_string_lossy(scope), None) + } else if value.is_object() && !value.is_array() { + let object = v8::Local::::try_from(value).map_err(|_| { + "image expects a non-empty image URL string or an object with image_url and optional detail".to_string() + })?; + let image_url_key = v8::String::new(scope, "image_url") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let detail_key = v8::String::new(scope, "detail") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let image_url = object + .get(scope, image_url_key.into()) + .filter(|value| value.is_string()) + .map(|value| value.to_rust_string_lossy(scope)) + .ok_or_else(|| { + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string() + })?; + let detail = match object.get(scope, detail_key.into()) { + Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)), + Some(value) if value.is_null() || value.is_undefined() => None, + Some(_) => return Err("image detail must be a string when provided".to_string()), + None => None, + }; + (image_url, detail) + } else { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + }; + + if image_url.is_empty() { + return Err( + "image expects a non-empty image URL string or an object with image_url and optional detail" + .to_string(), + ); + } + let lower = image_url.to_ascii_lowercase(); + if !(lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:")) + { + return Err("image expects an http(s) or data URL".to_string()); + } + + let detail = match detail { + 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() + ); + } + }) + } + None => None, + }; + + Ok(FunctionCallOutputContentItem::InputImage { image_url, detail }) + })(); + + match result { + Ok(item) => Ok(item), + Err(error_text) => { + throw_type_error(scope, &error_text); + Err(()) + } + } +} + +pub(super) fn v8_value_to_json( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let Some(stringified) = v8::json::stringify(&tc, value) else { + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + return Ok(None); + }; + serde_json::from_str(&stringified.to_rust_string_lossy(&tc)) + .map(Some) + .map_err(|err| format!("failed to serialize JavaScript value: {err}")) +} + +pub(super) fn json_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: &JsonValue, +) -> Option> { + let json = serde_json::to_string(value).ok()?; + let json = v8::String::new(scope, &json)?; + v8::json::parse(scope, json) +} + +pub(super) fn value_to_error_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> String { + if value.is_object() + && let Ok(object) = v8::Local::::try_from(value) + && let Some(key) = v8::String::new(scope, "stack") + && let Some(stack) = object.get(scope, key.into()) + && stack.is_string() + { + return stack.to_rust_string_lossy(scope); + } + value.to_rust_string_lossy(scope) +} + +pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) { + if let Some(message) = v8::String::new(scope, message) { + scope.throw_exception(message.into()); + } +} diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs new file mode 100644 index 0000000000..260b891d36 --- /dev/null +++ b/codex-rs/code-mode/src/service.rs @@ -0,0 +1,673 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::FunctionCallOutputContentItem; +use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; +use crate::runtime::ExecuteRequest; +use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeEvent; +use crate::runtime::RuntimeResponse; +use crate::runtime::TurnMessage; +use crate::runtime::WaitRequest; +use crate::runtime::spawn_runtime; + +#[async_trait] +pub trait CodeModeTurnHost: Send + Sync { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result; + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; +} + +#[derive(Clone)] +struct SessionHandle { + control_tx: mpsc::UnboundedSender, + runtime_tx: std::sync::mpsc::Sender, +} + +struct Inner { + stored_values: Mutex>, + sessions: Mutex>, + turn_message_tx: mpsc::UnboundedSender, + turn_message_rx: Arc>>, + next_cell_id: AtomicU64, +} + +pub struct CodeModeService { + inner: Arc, +} + +impl CodeModeService { + pub fn new() -> Self { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub async fn stored_values(&self) -> HashMap { + self.inner.stored_values.lock().await.clone() + } + + pub async fn replace_stored_values(&self, values: HashMap) { + *self.inner.stored_values.lock().await = values; + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let cell_id = self + .inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request.clone(), event_tx)?; + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (response_tx, response_rx) = oneshot::channel(); + + self.inner.sessions.lock().await.insert( + cell_id.clone(), + SessionHandle { + control_tx: control_tx.clone(), + runtime_tx: runtime_tx.clone(), + }, + ); + + tokio::spawn(run_session_control( + Arc::clone(&self.inner), + SessionControlContext { + cell_id: cell_id.clone(), + runtime_tx, + runtime_terminate_handle, + }, + event_rx, + control_rx, + response_tx, + request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS), + )); + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn wait(&self, request: WaitRequest) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(missing_cell_response(cell_id)); + }; + let (response_tx, response_rx) = oneshot::channel(); + let control_message = if request.terminate { + SessionControlCommand::Terminate { response_tx } + } else { + SessionControlCommand::Poll { + yield_time_ms: request.yield_time_ms, + response_tx, + } + }; + if handle.control_tx.send(control_message).is_err() { + return Ok(missing_cell_response(cell_id)); + } + match response_rx.await { + Ok(response) => Ok(response), + Err(_) => Ok(missing_cell_response(request.cell_id)), + } + } + + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let inner = Arc::clone(&self.inner); + let turn_message_rx = Arc::clone(&self.inner.turn_message_rx); + + tokio::spawn(async move { + loop { + let next_message = tokio::select! { + _ = &mut shutdown_rx => break, + message = async { + let mut turn_message_rx = turn_message_rx.lock().await; + turn_message_rx.recv().await + } => message, + }; + let Some(next_message) = next_message else { + break; + }; + match next_message { + TurnMessage::Notify { + cell_id, + call_id, + text, + } => { + if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + TurnMessage::ToolCall { + cell_id, + id, + name, + input, + } => { + let host = Arc::clone(&host); + let inner = Arc::clone(&inner); + tokio::spawn(async move { + let response = host + .invoke_tool(name, input, CancellationToken::new()) + .await; + let runtime_tx = inner + .sessions + .lock() + .await + .get(&cell_id) + .map(|handle| handle.runtime_tx.clone()); + let Some(runtime_tx) = runtime_tx else { + return; + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); + } + } + } + }); + + CodeModeTurnWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} + +impl Default for CodeModeService { + fn default() -> Self { + Self::new() + } +} + +pub struct CodeModeTurnWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeTurnWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +enum SessionControlCommand { + Poll { + yield_time_ms: u64, + response_tx: oneshot::Sender, + }, + Terminate { + response_tx: oneshot::Sender, + }, +} + +struct PendingResult { + content_items: Vec, + stored_values: HashMap, + error_text: Option, +} + +struct SessionControlContext { + cell_id: String, + runtime_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, +} + +fn missing_cell_response(cell_id: String) -> RuntimeResponse { + RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), + stored_values: HashMap::new(), + } +} + +fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { + RuntimeResponse::Result { + cell_id: cell_id.to_string(), + content_items: result.content_items, + stored_values: result.stored_values, + error_text: result.error_text, + } +} + +fn send_or_buffer_result( + cell_id: &str, + result: PendingResult, + response_tx: &mut Option>, + pending_result: &mut Option, +) -> bool { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(pending_result_response(cell_id, result)); + return true; + } + + *pending_result = Some(result); + false +} + +async fn run_session_control( + inner: Arc, + context: SessionControlContext, + mut event_rx: mpsc::UnboundedReceiver, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: oneshot::Sender, + initial_yield_time_ms: u64, +) { + let SessionControlContext { + cell_id, + runtime_tx, + runtime_terminate_handle, + } = context; + let mut content_items = Vec::new(); + let mut pending_result: Option = None; + let mut response_tx = Some(initial_response_tx); + let mut termination_requested = false; + let mut runtime_closed = false; + let mut yield_timer: Option>> = None; + + loop { + tokio::select! { + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + } => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + if pending_result.is_none() { + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values: HashMap::new(), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + } + RuntimeEvent::ContentItem(item) => { + content_items.push(item); + } + RuntimeEvent::YieldRequested => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + RuntimeEvent::Notify { call_id, text } => { + let _ = inner.turn_message_tx.send(TurnMessage::Notify { + cell_id: cell_id.clone(), + call_id, + text, + }); + } + RuntimeEvent::ToolCall { id, name, input } => { + let _ = inner.turn_message_tx.send(TurnMessage::ToolCall { + cell_id: cell_id.clone(), + id, + name, + input, + }); + } + RuntimeEvent::Result { + stored_values, + error_text, + } => { + yield_timer = None; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values, + error_text, + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + } + } + maybe_command = control_rx.recv() => { + let Some(command) = maybe_command else { + break; + }; + match command { + SessionControlCommand::Poll { + yield_time_ms, + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + response_tx = Some(next_response_tx); + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + } + SessionControlCommand::Terminate { response_tx: next_response_tx } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + + response_tx = Some(next_response_tx); + termination_requested = true; + yield_timer = None; + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); + if runtime_closed { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } else { + continue; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + } + } + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + inner.sessions.lock().await.remove(&cell_id); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicU64; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::Mutex; + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + use super::CodeModeService; + use super::Inner; + use super::RuntimeCommand; + use super::RuntimeResponse; + use super::SessionControlCommand; + use super::SessionControlContext; + use super::run_session_control; + use crate::FunctionCallOutputContentItem; + use crate::runtime::ExecuteRequest; + use crate::runtime::RuntimeEvent; + use crate::runtime::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + fn test_inner() -> Arc { + let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel(); + Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx: Arc::new(Mutex::new(turn_message_rx)), + next_cell_id: AtomicU64::new(1), + }) + } + + #[tokio::test] + async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("https://example.com/image.jpg"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn terminate_waits_for_runtime_shutdown_before_responding() { + let inner = test_inner(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + ExecuteRequest { + source: "await new Promise(() => {})".to_string(), + yield_time_ms: None, + ..execute_request("") + }, + runtime_event_tx, + ) + .unwrap(); + + tokio::spawn(run_session_control( + inner, + SessionControlContext { + cell_id: "cell-1".to_string(), + runtime_tx: runtime_tx.clone(), + runtime_terminate_handle, + }, + event_rx, + control_rx, + initial_response_tx, + 60_000, + )); + + event_tx.send(RuntimeEvent::Started).unwrap(); + event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert_eq!( + initial_response_rx.await.unwrap(), + RuntimeResponse::Yielded { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); + control_tx + .send(SessionControlCommand::Terminate { + response_tx: terminate_response_tx, + }) + .unwrap(); + let terminate_response = async { terminate_response_rx.await.unwrap() }; + tokio::pin!(terminate_response); + assert!( + tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) + .await + .is_err() + ); + + drop(event_tx); + + assert_eq!( + terminate_response.await, + RuntimeResponse::Terminated { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index b21d79d676..9c20345170 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -31,6 +31,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3558fd9f60..c27ec5460e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the more specific writable child must reopen under a denied parent. The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and -falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is -missing, Codex also surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. +supports the required argv-rewrite flags, and falls back to the vendored +bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is +missing or too old to support the required flags, Codex also surfaces a startup +warning through its normal notification path instead of printing directly from +the sandbox helper. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 009ef57369..0b7cf3ced9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1524,13 +1524,21 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": "string" + }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "type": "object" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 10cbd441b4..282e29c48a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,6 +1,6 @@ use crate::agent::AgentStatus; -use crate::agent::guards::AgentMetadata; -use crate::agent::guards::Guards; +use crate::agent::registry::AgentMetadata; +use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; @@ -21,6 +21,7 @@ use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -80,14 +81,14 @@ fn agent_nickname_candidates( /// spawn new agents and the inter-agent communication layer. /// An `AgentControl` instance is intended to be created at most once per root thread/session /// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root, -/// which keeps the guards scoped to that root thread rather than the entire `ThreadManager`. +/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, - state: Arc, + state: Arc, } impl AgentControl { @@ -265,6 +266,7 @@ impl AgentControl { new_thread.thread_id, notification_source, child_reference, + agent_metadata.agent_path.clone(), ); Ok(LiveAgent { @@ -436,6 +438,7 @@ impl AgentControl { resumed_thread.thread_id, Some(notification_source.clone()), child_reference, + agent_metadata.agent_path.clone(), ); self.persist_thread_spawn_edge_for_source( resumed_thread.thread.as_ref(), @@ -454,20 +457,52 @@ impl AgentControl { items: Vec, ) -> CodexResult { let state = self.upgrade()?; - let result = state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await; - if matches!(result, Err(CodexErr::InternalAgentDied)) { - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - } - result + self.handle_thread_request_result( + agent_id, + &state, + state + .send_op( + agent_id, + Op::UserInput { + items, + final_output_json_schema: None, + }, + ) + .await, + ) + .await + } + + /// Append a prebuilt message to an existing agent thread outside the normal user-input path. + #[cfg(test)] + pub(crate) async fn append_message( + &self, + agent_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state.append_message(agent_id, message).await, + ) + .await + } + + pub(crate) async fn send_inter_agent_communication( + &self, + agent_id: ThreadId, + communication: InterAgentCommunication, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state + .send_op(agent_id, Op::InterAgentCommunication { communication }) + .await, + ) + .await } /// Interrupt the current task for an existing agent thread. @@ -476,6 +511,19 @@ impl AgentControl { state.send_op(agent_id, Op::Interrupt).await } + async fn handle_thread_request_result( + &self, + agent_id: ThreadId, + state: &Arc, + result: CodexResult, + ) -> CodexResult { + if matches!(result, Err(CodexErr::InternalAgentDied)) { + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + } + result + } + /// Submit a shutdown request for a live agent without marking it explicitly closed in /// persisted spawn-edge state. pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { @@ -641,6 +689,7 @@ impl AgentControl { child_thread_id: ThreadId, session_source: Option, child_reference: String, + child_agent_path: Option, ) { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. @@ -671,6 +720,39 @@ impl AgentControl { let Ok(state) = control.upgrade() else { return; }; + let child_thread = state.get_thread(child_thread_id).await.ok(); + if let Some(child_agent_path) = child_agent_path + && child_thread + .as_ref() + .map(|thread| thread.enabled(Feature::MultiAgentV2)) + .unwrap_or(true) + { + let AgentStatus::Completed(Some(content)) = &status else { + return; + }; + let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else { + return; + }; + let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else { + return; + }; + let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path) + else { + return; + }; + let _ = control + .send_inter_agent_communication( + parent_thread_id, + InterAgentCommunication::new( + child_agent_path, + parent_agent_path, + Vec::new(), + content.clone(), + ), + ) + .await; + return; + } let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; @@ -686,7 +768,7 @@ impl AgentControl { #[allow(clippy::too_many_arguments)] fn prepare_thread_spawn( &self, - reservation: &mut crate::agent::guards::SpawnReservation, + reservation: &mut crate::agent::registry::SpawnReservation, config: &crate::config::Config, parent_thread_id: ThreadId, depth: i32, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 20c051f853..2cf7398f7d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use chrono::Utc; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; @@ -125,6 +127,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { }) } +fn history_contains_assistant_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { let wait = async { loop { @@ -382,6 +407,61 @@ async fn send_input_submits_user_message() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn append_message_records_assistant_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let message = + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; + + let submission_id = harness + .control + .append_message( + thread_id, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: message.to_string(), + }], + end_turn: None, + phase: None, + }, + ) + .await + .expect("append_message should succeed"); + assert!(!submission_id.is_empty()); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == message + )) + ) + }); + if recorded { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("assistant message should be recorded"); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; @@ -882,6 +962,223 @@ async fn spawn_child_completion_notifies_parent_history() { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } +#[tokio::test] +async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, _) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + timeout(Duration::from_secs(2), async { + loop { + let delivered = harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.other_recipients.is_empty() + && communication.content == "done" + ) + }); + if delivered { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("completion watcher should send inter-agent communication"); + + let worker_thread = harness + .manager + .get_thread(worker_thread_id) + .await + .expect("worker thread should exist"); + let expected_message = InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + "done".to_string(), + ); + timeout(Duration::from_secs(2), async { + loop { + let history_items = worker_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + if history_contains_assistant_inter_agent_communication( + &history_items, + &expected_message, + ) && !has_subagent_notification(&history_items) + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("worker should record assistant inter-agent message"); +} + +#[tokio::test] +async fn multi_agent_v2_completion_ignores_dead_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, root_thread) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + harness + .control + .shutdown_live_agent(worker_thread_id) + .await + .expect("worker shutdown should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + sleep(Duration::from_millis(100)).await; + + assert!( + !harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.content == "done" + ) + }) + ); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + "done".to_string(), + ) + )); + assert!(!has_subagent_notification(&root_history_items)); +} + #[tokio::test] async fn completion_watcher_notifies_parent_when_child_is_missing() { let harness = AgentControlHarness::new().await; @@ -898,6 +1195,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { agent_role: Some("explorer".to_string()), })), child_thread_id.to_string(), + None, ); assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 681f993a94..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,11 +1,11 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -mod guards; +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 guards::exceeds_thread_spawn_depth_limit; -pub(crate) use guards::next_thread_spawn_depth; +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/agent/guards.rs b/codex-rs/core/src/agent/registry.rs similarity index 98% rename from codex-rs/core/src/agent/guards.rs rename to codex-rs/core/src/agent/registry.rs index 665c02ebfb..af545e8c97 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -20,7 +20,7 @@ use std::sync::atomic::Ordering; /// This structure is shared by all agents in the same user session (because the `AgentControl` /// is). #[derive(Default)] -pub(crate) struct Guards { +pub(crate) struct AgentRegistry { active_agents: Mutex, total_count: AtomicUsize, } @@ -75,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo depth > max_depth } -impl Guards { +impl AgentRegistry { pub(crate) fn reserve_spawn_slot( self: &Arc, max_threads: Option, @@ -263,7 +263,7 @@ impl Guards { } pub(crate) struct SpawnReservation { - state: Arc, + state: Arc, active: bool, reserved_agent_nickname: Option, reserved_agent_path: Option, @@ -311,5 +311,5 @@ impl Drop for SpawnReservation { } #[cfg(test)] -#[path = "guards_tests.rs"] +#[path = "registry_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agent/guards_tests.rs b/codex-rs/core/src/agent/registry_tests.rs similarity index 70% rename from codex-rs/core/src/agent/guards_tests.rs rename to codex-rs/core/src/agent/registry_tests.rs index 9da4cec848..43d91952a7 100644 --- a/codex-rs/core/src/agent/guards_tests.rs +++ b/codex-rs/core/src/agent/registry_tests.rs @@ -52,22 +52,22 @@ fn non_thread_spawn_subagents_default_to_depth_zero() { #[test] fn reservation_drop_releases_slot() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); drop(reservation); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released"); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released"); drop(reservation); } #[test] fn commit_holds_slot_until_release() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let thread_id = ThreadId::new(); reservation.commit(agent_metadata(thread_id)); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should be enforced"), Err(err) => err, }; @@ -76,8 +76,8 @@ fn commit_holds_slot_until_release() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(thread_id); - let reservation = guards + registry.release_spawned_thread(thread_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after thread removal"); drop(reservation); @@ -85,14 +85,14 @@ fn commit_holds_slot_until_release() { #[test] fn release_ignores_unknown_thread_id() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let thread_id = ThreadId::new(); reservation.commit(agent_metadata(thread_id)); - guards.release_spawned_thread(ThreadId::new()); + registry.release_spawned_thread(ThreadId::new()); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should still be enforced"), Err(err) => err, }; @@ -101,8 +101,8 @@ fn release_ignores_unknown_thread_id() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(thread_id); - let reservation = guards + registry.release_spawned_thread(thread_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after real thread removal"); drop(reservation); @@ -110,20 +110,20 @@ fn release_ignores_unknown_thread_id() { #[test] fn release_is_idempotent_for_registered_threads() { - let guards = Arc::new(Guards::default()); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); let first_id = ThreadId::new(); reservation.commit(agent_metadata(first_id)); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused"); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused"); let second_id = ThreadId::new(); reservation.commit(agent_metadata(second_id)); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let err = match guards.reserve_spawn_slot(Some(1)) { + let err = match registry.reserve_spawn_slot(Some(1)) { Ok(_) => panic!("limit should still be enforced"), Err(err) => err, }; @@ -132,8 +132,8 @@ fn release_is_idempotent_for_registered_threads() { }; assert_eq!(max_threads, 1); - guards.release_spawned_thread(second_id); - let reservation = guards + registry.release_spawned_thread(second_id); + let reservation = registry .reserve_spawn_slot(Some(1)) .expect("slot released after second thread removal"); drop(reservation); @@ -141,15 +141,15 @@ fn release_is_idempotent_for_registered_threads() { #[test] fn failed_spawn_keeps_nickname_marked_used() { - let guards = Arc::new(Guards::default()); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve agent name"); assert_eq!(agent_nickname, "alpha"); drop(reservation); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) .expect("unused name should still be preferred"); @@ -158,8 +158,10 @@ fn failed_spawn_keeps_nickname_marked_used() { #[test] fn agent_nickname_resets_used_pool_when_exhausted() { - let guards = Arc::new(Guards::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve first agent name"); @@ -167,14 +169,14 @@ fn agent_nickname_resets_used_pool_when_exhausted() { first.commit(agent_metadata(first_id)); assert_eq!(first_name, "alpha"); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("name should be reused after pool reset"); assert_eq!(second_name, "alpha the 2nd"); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -183,9 +185,11 @@ fn agent_nickname_resets_used_pool_when_exhausted() { #[test] fn released_nickname_stays_used_until_pool_reset() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve first agent name"); @@ -193,9 +197,9 @@ fn released_nickname_stays_used_until_pool_reset() { first.commit(agent_metadata(first_id)); assert_eq!(first_name, "alpha"); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second @@ -204,15 +208,17 @@ fn released_nickname_stays_used_until_pool_reset() { assert_eq!(second_name, "beta"); let second_id = ThreadId::new(); second.commit(agent_metadata(second_id)); - guards.release_spawned_thread(second_id); + registry.release_spawned_thread(second_id); - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) .expect("pool reset should permit a duplicate"); let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); assert!(expected_names.contains(&third_name)); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -221,18 +227,20 @@ fn released_nickname_stays_used_until_pool_reset() { #[test] fn repeated_resets_advance_the_ordinal_suffix() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) .expect("reserve first agent name"); let first_id = ThreadId::new(); first.commit(agent_metadata(first_id)); assert_eq!(first_name, "Plato"); - guards.release_spawned_thread(first_id); + registry.release_spawned_thread(first_id); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); let second_name = second @@ -241,14 +249,16 @@ fn repeated_resets_advance_the_ordinal_suffix() { let second_id = ThreadId::new(); second.commit(agent_metadata(second_id)); assert_eq!(second_name, "Plato the 2nd"); - guards.release_spawned_thread(second_id); + registry.release_spawned_thread(second_id); - let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let mut third = registry + .reserve_spawn_slot(None) + .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) .expect("reserve third agent name"); assert_eq!(third_name, "Plato the 3rd"); - let active_agents = guards + let active_agents = registry .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -257,27 +267,29 @@ fn repeated_resets_advance_the_ordinal_suffix() { #[test] fn register_root_thread_indexes_root_path() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); let root_thread_id = ThreadId::new(); - guards.register_root_thread(root_thread_id); + registry.register_root_thread(root_thread_id); assert_eq!( - guards.agent_id_for_path(&AgentPath::root()), + registry.agent_id_for_path(&AgentPath::root()), Some(root_thread_id) ); } #[test] fn reserved_agent_path_is_released_when_spawn_fails() { - let guards = Arc::new(Guards::default()); - let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(None) + .expect("reserve first slot"); first .reserve_agent_path(&agent_path("/root/researcher")) .expect("reserve first path"); drop(first); - let mut second = guards + let mut second = registry .reserve_spawn_slot(None) .expect("reserve second slot"); second @@ -287,9 +299,9 @@ fn reserved_agent_path_is_released_when_spawn_fails() { #[test] fn committed_agent_path_is_indexed_until_release() { - let guards = Arc::new(Guards::default()); + let registry = Arc::new(AgentRegistry::default()); let thread_id = ThreadId::new(); - let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); reservation .reserve_agent_path(&agent_path("/root/researcher")) .expect("reserve path"); @@ -300,13 +312,13 @@ fn committed_agent_path_is_indexed_until_release() { }); assert_eq!( - guards.agent_id_for_path(&agent_path("/root/researcher")), + registry.agent_id_for_path(&agent_path("/root/researcher")), Some(thread_id) ); - guards.release_spawned_thread(thread_id); + registry.release_spawned_thread(thread_id); assert_eq!( - guards.agent_id_for_path(&agent_path("/root/researcher")), + registry.agent_id_for_path(&agent_path("/root/researcher")), None ); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 12270bb6dd..5e428362f2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -186,9 +186,41 @@ mod rollout_reconstruction_tests; pub enum SteerInputError { NoActiveTurn(Vec), ExpectedTurnMismatch { expected: String, actual: String }, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, EmptyInput, } +impl SteerInputError { + fn to_error_event(&self) -> ErrorEvent { + match self { + Self::NoActiveTurn(_) => ErrorEvent { + message: "no active turn to steer".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent { + message: format!("expected active turn id `{expected}` but found `{actual}`"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ActiveTurnNotSteerable { turn_kind } => { + let turn_kind_label = match turn_kind { + NonSteerableTurnKind::Review => "review", + NonSteerableTurnKind::Compact => "compact", + }; + ErrorEvent { + message: format!("cannot steer a {turn_kind_label} turn"), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: *turn_kind, + }), + } + } + Self::EmptyInput => ErrorEvent { + message: "input must not be empty".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + } + } +} + /// Notes from the previous real user turn. /// /// Conceptually this is the same role that `previous_model` used to fill, but @@ -333,6 +365,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; @@ -765,6 +798,7 @@ pub(crate) struct Session { pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, + idle_pending_input: Mutex>, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, js_repl: Arc, @@ -776,6 +810,7 @@ pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, pub(crate) implicit_invocation_seen_skills: Arc>>, } + impl TurnSkillsContext { pub(crate) fn new(outcome: Arc) -> Self { Self { @@ -1862,6 +1897,7 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: GuardianReviewSessionManager::default(), services, js_repl, @@ -3439,6 +3475,7 @@ impl Session { DeveloperInstructions::from_policy( turn_context.sandbox_policy.get(), turn_context.approval_policy.value(), + turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), &turn_context.cwd, turn_context @@ -3858,12 +3895,27 @@ impl Session { }); } + match active_turn.tasks.first().map(|(_, task)| task.kind) { + Some(crate::state::TaskKind::Regular) => {} + Some(crate::state::TaskKind::Review) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }); + } + Some(crate::state::TaskKind::Compact) => { + return Err(SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }); + } + None => return Err(SteerInputError::NoActiveTurn(input)), + } + let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); Ok(active_turn_id.clone()) } - /// Returns the input if there was no task running to inject into + /// Returns the input if there was no task running to inject into. pub async fn inject_response_items( &self, input: Vec, @@ -3904,6 +3956,24 @@ impl Session { } } + /// 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() + } + pub async fn has_pending_input(&self) -> bool { let active = self.active_turn.lock().await; match active.as_ref() { @@ -4236,6 +4306,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } + Op::InterAgentCommunication { communication } => { + handlers::inter_agent_communication(&sess, sub.id.clone(), communication).await; + false + } Op::ExecApproval { id: approval_id, turn_id, @@ -4415,6 +4489,7 @@ mod handlers { use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; @@ -4475,6 +4550,7 @@ mod handlers { Op::UserTurn { cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -4500,7 +4576,7 @@ mod handlers { SessionSettingsUpdate { cwd: Some(cwd), approval_policy: Some(approval_policy), - approvals_reviewer: None, + approvals_reviewer, sandbox_policy: Some(sandbox_policy), windows_sandbox_level: None, collaboration_mode, @@ -4525,29 +4601,61 @@ mod handlers { _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { + 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; }; sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; - current_context.session_telemetry.user_prompt(&items); - - // Attempt to inject input into current task. - if let Err(SteerInputError::NoActiveTurn(items)) = - sess.steer_input(items, /*expected_turn_id*/ None).await + match sess + .steer_input(items.clone(), /*expected_turn_id*/ None) + .await { - sess.refresh_mcp_servers_if_requested(¤t_context) + Ok(_) => current_context.session_telemetry.user_prompt(&items), + Err(SteerInputError::NoActiveTurn(items)) => { + current_context.session_telemetry.user_prompt(&items); + sess.refresh_mcp_servers_if_requested(¤t_context) + .await; + sess.spawn_task( + Arc::clone(¤t_context), + items, + crate::tasks::RegularTask::new(), + ) .await; - sess.spawn_task( - Arc::clone(¤t_context), - items, - crate::tasks::RegularTask::new(), - ) - .await; + } + Err(err) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(err.to_error_event()), + }) + .await; + } } } + pub async fn inter_agent_communication( + sess: &Arc, + sub_id: String, + communication: InterAgentCommunication, + ) { + let pending_item = communication.to_response_input_item(); + if sess + .inject_response_items(vec![pending_item.clone()]) + .await + .is_ok() + { + return; + } + + let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + sess.queue_response_items_for_next_turn(vec![pending_item]) + .await; + sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) + .await; + } + pub async fn run_user_shell_command(sess: &Arc, sub_id: String, command: String) { if let Some((turn_context, cancellation_token)) = sess.active_turn_context_and_cancellation_token().await @@ -5383,7 +5491,7 @@ pub(crate) async fn run_turn( prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { - if input.is_empty() { + if input.is_empty() && !sess.has_pending_input().await { return None; } @@ -5510,20 +5618,6 @@ pub(crate) async fn run_turn( &available_connectors, &skill_name_counts_lower, )); - // Explicit plugin mentions can make a plugin's enabled apps callable for - // this turn without persisting those connectors as sticky user selections. - let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); - turn_enabled_connectors.extend( - mentioned_plugins - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .filter(|connector_id| { - available_connectors - .iter() - .any(|connector| connector.is_enabled && connector.id == *connector_id) - }), - ); let connector_names_by_id = available_connectors .iter() .map(|connector| (connector.id.as_str(), connector.name.as_str())) @@ -5539,25 +5633,33 @@ pub(crate) async fn run_turn( }) .collect::>(); - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - let mut last_agent_message: Option = None; if run_pending_session_start_hooks(&sess, &turn_context).await { - return last_agent_message; + return None; } - let user_prompt_submit_outcome = - run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message()) - .await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( + 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, - user_prompt_submit_outcome.additional_contexts, + UserMessageItem::new(&input).message(), ) .await; - return last_agent_message; - } - let additional_contexts = user_prompt_submit_outcome.additional_contexts; + if user_prompt_submit_outcome.should_stop { + record_additional_contexts( + &sess, + &turn_context, + user_prompt_submit_outcome.additional_contexts, + ) + .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 + }; sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); @@ -5568,17 +5670,17 @@ pub(crate) async fn run_turn( } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; - // Track the previous-turn baseline from the regular user-turn path only so - // standalone tasks (compact/shell/review/undo) 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; + if !input.is_empty() { + // Track the previous-turn baseline from the regular user-turn path only so + // standalone tasks (compact/shell/review/undo) 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; + } if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -5589,8 +5691,10 @@ pub(crate) async fn run_turn( .await; } + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; + 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 // many turns, from the perspective of the user, it is a single turn. @@ -5675,7 +5779,7 @@ pub(crate) async fn run_turn( &mut client_session, turn_metadata_header.as_deref(), sampling_request_input, - &turn_enabled_connectors, + &explicitly_enabled_connectors, skills_outcome, &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), diff --git a/codex-rs/core/src/codex/rollout_reconstruction.rs b/codex-rs/core/src/codex/rollout_reconstruction.rs index 11ddbc1928..a4c042af0c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context_manager::is_user_turn_boundary; // Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with // the resume/fork hydration metadata derived from the same replay. @@ -201,9 +202,12 @@ impl Session { ); } } - RolloutItem::ResponseItem(_) - | RolloutItem::EventMsg(_) - | RolloutItem::SessionMeta(_) => {} + RolloutItem::ResponseItem(response_item) => { + let active_segment = + active_segment.get_or_insert_with(ActiveReplaySegment::default); + active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item); + } + RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {} } if base_replacement_history.is_some() diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 6cc99a2907..33fb5f9af7 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -3,9 +3,11 @@ use super::*; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; use crate::protocol::ResumedHistory; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -33,6 +35,24 @@ fn assistant_message(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_message(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + ); + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: serde_json::to_string(&communication).unwrap(), + }], + end_turn: None, + phase: None, + } +} + #[tokio::test] async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings() { @@ -430,6 +450,94 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad ); } +#[tokio::test] +async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { + let (session, turn_context) = make_session_and_context().await; + let first_context_item = turn_context.to_turn_context_item(); + let first_turn_id = first_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let assistant_turn_id = "assistant-instruction-turn".to_string(); + let assistant_turn_context = TurnContextItem { + turn_id: Some(assistant_turn_id.clone()), + ..first_context_item.clone() + }; + let assistant_instruction = inter_agent_assistant_message("continue"); + let assistant_reply = assistant_message("worker reply"); + + let rollout_items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: first_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + RolloutItem::TurnContext(first_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 1 user")), + RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: first_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: assistant_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::TurnContext(assistant_turn_context), + RolloutItem::ResponseItem(assistant_instruction), + RolloutItem::ResponseItem(assistant_reply), + RolloutItem::EventMsg(EventMsg::TurnComplete( + codex_protocol::protocol::TurnCompleteEvent { + turn_id: assistant_turn_id, + last_agent_message: None, + }, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack( + codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + )), + ]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + assert_eq!( + reconstructed.history, + vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant") + ] + ); + assert_eq!( + reconstructed.previous_turn_settings, + Some(PreviousTurnSettings { + model: turn_context.model_info.slug.clone(), + realtime_active: Some(turn_context.realtime_active), + }) + ); + assert_eq!( + serde_json::to_value(reconstructed.reference_context_item) + .expect("serialize reconstructed reference context item"), + serde_json::to_value(Some(first_context_item)) + .expect("serialize expected reference context item") + ); +} + #[tokio::test] async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5412eff29..5d6769e0ea 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -24,6 +24,7 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; @@ -64,17 +65,31 @@ use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_network_proxy::NetworkProxyConfig; use codex_otel::TelemetryAuthMode; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; use core_test_support::tracing::install_test_tracing; +use core_test_support::wait_for_event; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use std::path::Path; @@ -641,6 +656,21 @@ fn filter_connectors_for_input_skips_disabled_connectors() { 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")]; @@ -1100,6 +1130,111 @@ async fn record_initial_history_reconstructs_forked_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { + let server = start_mock_server().await; + mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let first_forked_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::OnRequest); + }); + let initial = builder.build(&server).await?; + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "fork seed".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let mut fork_config = initial.config.clone(); + fork_config.permissions.approval_policy = + codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); + let forked = initial + .thread_manager + .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .await?; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: forked.session_configured.model.clone(), + reasoning_effort: None, + 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, + 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 { + items: vec![UserInput::Text { + text: "after fork".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = first_forked_request.single_request(); + let snapshot = context_snapshot::format_labeled_requests_snapshot( + "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + &[("First Forked Turn Request", &request)], + &ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) + .strip_capability_instructions() + .strip_agents_md_user_context(), + ); + + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path("snapshots"); + settings.set_prepend_module_to_snapshot(false); + settings.bind(|| { + insta::assert_snapshot!( + "codex_core__codex_tests__fork_startup_context_then_first_turn_diff", + snapshot + ); + }); + + Ok(()) +} + #[tokio::test] async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; @@ -2547,6 +2682,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), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -2877,6 +3013,41 @@ fn op_kind_distinguishes_turn_ops() { ); } +#[tokio::test] +async fn user_turn_updates_approvals_reviewer() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + let config = session.get_config().await; + + handlers::user_input_or_turn( + &session, + "sub-1".to_string(), + Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + cwd: config.cwd.clone(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: turn_context.model_info.slug.clone(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + service_tier: None, + final_output_json_schema: None, + collaboration_mode: None, + personality: config.personality, + }, + ) + .await; + + let state = session.state.lock().await; + assert_eq!( + state.session_configuration.approvals_reviewer, + crate::config::types::ApprovalsReviewer::GuardianSubagent + ); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { @@ -3346,6 +3517,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, js_repl, @@ -4373,6 +4545,43 @@ async fn steer_input_enforces_expected_turn_id() { } } +#[tokio::test] +async fn steer_input_rejects_non_regular_turns() { + for (task_kind, turn_kind) in [ + (TaskKind::Review, NonSteerableTurnKind::Review), + (TaskKind::Compact, NonSteerableTurnKind::Compact), + ] { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await; + sess.spawn_task( + turn_context, + input, + NeverEndingTask { + kind: task_kind, + listen_to_cancellation_token: true, + }, + ) + .await; + + let steer_input = vec![UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }]; + let err = sess + .steer_input(steer_input, /*expected_turn_id*/ None) + .await + .expect_err("steering a non-regular turn should fail"); + + assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind }); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + } +} + #[tokio::test] async fn steer_input_returns_active_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; @@ -4459,6 +4668,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { assert_eq!(sess.get_pending_input().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; + let queued_item = ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "queued before wake".to_string(), + }], + }; + + sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) + .await; + + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + assert_eq!(sess.get_pending_input().await, vec![queued_item]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e016fec977..9fa7069118 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -122,29 +122,61 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let pending_item = ResponseInputItem::Message { + let message = ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], + end_turn: None, + phase: None, }; - let pending_items = vec![pending_item]; - let Err(items_without_active_turn) = self + let pending_item = match pending_message_input_item(&message) { + Ok(pending_item) => pending_item, + Err(err) => { + debug_assert!(false, "session-prefix message append should succeed: {err}"); + return; + } + }; + if self .codex .session - .inject_response_items(pending_items) + .inject_response_items(vec![pending_item]) .await - else { - return; - }; + .is_err() + { + let turn_context = self.codex.session.new_default_turn().await; + self.codex + .session + .record_conversation_items(turn_context.as_ref(), &[message]) + .await; + } + } - let turn_context = self.codex.session.new_default_turn().await; - let items: Vec = items_without_active_turn - .into_iter() - .map(ResponseItem::from) - .collect(); - self.codex + /// Append a prebuilt message to the thread history without treating it as a user turn. + /// + /// If the thread already has an active turn, the message is queued as pending input for that + /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent + /// can consume that pending input through the normal turn pipeline. + #[cfg(test)] + pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; + if let Err(items) = self + .codex .session - .record_conversation_items(turn_context.as_ref(), &items) - .await; + .inject_response_items(vec![pending_item]) + .await + { + self.codex + .session + .queue_response_items_for_next_turn(items) + .await; + self.codex + .session + .ensure_task_for_queued_response_items() + .await; + } + + Ok(submission_id) } pub fn rollout_path(&self) -> Option { @@ -198,3 +230,15 @@ impl CodexThread { Ok(*guard) } } + +fn pending_message_input_item(message: &ResponseItem) -> CodexResult { + match message { + ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message { + role: role.clone(), + content: content.clone(), + }), + _ => Err(CodexErr::InvalidRequest( + "append_message only supports ResponseItem::Message".to_string(), + )), + } +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1c78759ec4..7786b30873 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5582,16 +5582,69 @@ shell_tool = true Ok(()) } +#[cfg(target_os = "linux")] #[test] -fn missing_system_bwrap_warning_matches_system_bwrap_presence() { - #[cfg(target_os = "linux")] - assert_eq!( - missing_system_bwrap_warning().is_some(), - !Path::new("/usr/bin/bwrap").is_file() - ); +fn system_bwrap_warning_reports_missing_system_bwrap() { + let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap")) + .expect("missing system bwrap should emit a warning"); - #[cfg(not(target_os = "linux"))] - assert!(missing_system_bwrap_warning().is_none()); + assert!(warning.contains("could not find system bubblewrap")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_reports_too_old_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let warning = system_bwrap_warning_for_path(fake_bwrap_path) + .expect("old system bwrap should emit a warning"); + + assert!(warning.contains("too old to support `--argv0`")); +} + +#[cfg(target_os = "linux")] +#[test] +fn system_bwrap_warning_skips_supported_system_bwrap() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn system_bwrap_warning_is_disabled_off_linux() { + assert!(system_bwrap_warning().is_none()); +} + +#[cfg(target_os = "linux")] +fn write_fake_bwrap(contents: &str) -> tempfile::TempPath { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::NamedTempFile; + + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path } #[tokio::test] @@ -5677,7 +5730,7 @@ approvals_reviewer = "guardian_subagent" } #[tokio::test] -async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5692,23 +5745,19 @@ smart_approvals = true .build() .await?; - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert!(!config.features.enabled(Feature::GuardianApproval)); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } #[tokio::test] -async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> { +async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -5719,106 +5768,6 @@ smart_approvals = true "#, )?; - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = true - -[profiles.guardian.features] -smart_approvals = false -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .harness_overrides(ConfigOverrides { - config_profile: Some("guardian".to_string()), - ..Default::default() - }) - .build() - .await?; - - assert!(!config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.features.legacy_feature_usages().count(), 0); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"approvals_reviewer = "user" - -[features] -smart_approvals = true -"#, - )?; - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = true")); - assert!(serialized.contains("approvals_reviewer = \"user\"")); - assert!(!serialized.contains("smart_approvals")); - - Ok(()) -} - -#[tokio::test] -async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -guardian_approval = false -smart_approvals = true -"#, - )?; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -5829,9 +5778,10 @@ smart_approvals = true assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("guardian_approval = false")); - assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\"")); - assert!(!serialized.contains("smart_approvals")); + assert!(serialized.contains("[profiles.guardian.features]")); + assert!(serialized.contains("smart_approvals = true")); + assert!(!serialized.contains("guardian_approval")); + assert!(!serialized.contains("approvals_reviewer")); Ok(()) } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 2865ace4b2..3f7e3b1170 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -46,8 +46,10 @@ pub enum ConfigEdit { RecordModelMigrationSeen { from: String, to: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), - /// Set or clear a skill config entry under `[[skills.config]]`. + /// Set or clear a skill config entry under `[[skills.config]]` by path. SetSkillConfig { path: PathBuf, enabled: bool }, + /// Set or clear a skill config entry under `[[skills.config]]` by name. + SetSkillConfigByName { name: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -60,6 +62,12 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +#[derive(Clone, Debug, PartialEq, Eq)] +enum SkillConfigSelector { + Name(String), + Path(PathBuf), +} + /// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { @@ -387,7 +395,10 @@ impl ConfigDocument { )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::SetSkillConfig { path, enabled } => { - Ok(self.set_skill_config(path.as_path(), *enabled)) + Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled)) + } + ConfigEdit::SetSkillConfigByName { name, enabled } => { + Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), @@ -478,8 +489,16 @@ impl ConfigDocument { true } - fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { - let normalized_path = normalize_skill_config_path(path); + fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { + let selector = match selector { + SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()), + SkillConfigSelector::Path(path) => { + SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path))) + } + }; + if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { + return false; + } let mut remove_skills_table = false; let mut mutated = false; @@ -538,12 +557,8 @@ impl ConfigDocument { }; let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - table - .get("path") - .and_then(|item| item.as_str()) - .map(Path::new) - .map(normalize_skill_config_path) - .filter(|value| *value == normalized_path) + skill_config_selector_from_table(table) + .filter(|value| value == &selector) .map(|_| idx) }); @@ -561,7 +576,7 @@ impl ConfigDocument { } else if let Some(index) = existing_index { for (idx, table) in overrides.iter_mut().enumerate() { if idx == index { - table["path"] = value(normalized_path); + write_skill_config_selector(table, &selector); table["enabled"] = value(false); mutated = true; break; @@ -570,7 +585,7 @@ impl ConfigDocument { } else { let mut entry = TomlTable::new(); entry.set_implicit(false); - entry["path"] = value(normalized_path); + write_skill_config_selector(&mut entry, &selector); entry["enabled"] = value(false); overrides.push(entry); mutated = true; @@ -699,6 +714,38 @@ fn normalize_skill_config_path(path: &Path) -> String { .to_string() } +fn skill_config_selector_from_table(table: &TomlTable) -> Option { + let path = table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path)))); + let name = table + .get("name") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| SkillConfigSelector::Name(name.to_string())); + + match (path, name) { + (Some(selector), None) | (None, Some(selector)) => Some(selector), + _ => None, + } +} + +fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) { + match selector { + SkillConfigSelector::Name(name) => { + table.remove("path"); + table["name"] = value(name.clone()); + } + SkillConfigSelector::Path(path) => { + table.remove("name"); + table["path"] = value(path.to_string_lossy().to_string()); + } + } +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 5a31d84dd0..632716f00d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -110,6 +110,27 @@ enabled = false assert_eq!(contents, ""); } +#[test] +fn set_skill_config_writes_name_selector_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfigByName { + name: "github:yeet".to_string(), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#; + assert_eq!(contents, expected); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17abe55c46..cbbd023905 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -96,6 +96,8 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::process::Command; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; @@ -104,7 +106,6 @@ use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; -use toml_edit::value; pub(crate) mod agent_roles; pub mod edit; @@ -154,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ ]; #[cfg(target_os = "linux")] -pub fn missing_system_bwrap_warning() -> Option { - if Path::new(SYSTEM_BWRAP_PATH).is_file() { - None - } else { - Some(format!( - "Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime." - )) - } +pub fn system_bwrap_warning() -> Option { + system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH)) } #[cfg(not(target_os = "linux"))] -pub fn missing_system_bwrap_warning() -> Option { +pub fn system_bwrap_warning() -> Option { None } +#[cfg(target_os = "linux")] +fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option { + if !system_bwrap_path.is_file() { + return Some(format!( + "Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )); + } + if system_bwrap_supports_argv0(system_bwrap_path) { + return None; + } + + Some(format!( + "Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", + system_bwrap_path.display() + )) +} + +#[cfg(target_os = "linux")] +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; let trimmed = raw.trim(); @@ -652,9 +678,6 @@ impl ConfigBuilder { fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; - if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let cli_overrides = cli_overrides.unwrap_or_default(); let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); @@ -702,111 +725,6 @@ impl ConfigBuilder { } } -fn config_scope_segments(scope: &[String], key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push(key.to_string()); - segments -} - -fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec { - let mut segments = scope.to_vec(); - segments.push("features".to_string()); - segments.push(feature_key.to_string()); - segments -} - -fn push_smart_approvals_alias_migration_edits( - edits: &mut Vec, - scope: &[String], - features: &FeaturesToml, - approvals_reviewer_missing: bool, -) { - let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else { - return; - }; - let canonical_enabled = features - .entries - .get("guardian_approval") - .copied() - .unwrap_or(alias_enabled); - - if !features.entries.contains_key("guardian_approval") { - edits.push(ConfigEdit::SetPath { - segments: feature_scope_segments(scope, "guardian_approval"), - value: value(alias_enabled), - }); - } - if canonical_enabled && approvals_reviewer_missing { - edits.push(ConfigEdit::SetPath { - segments: config_scope_segments(scope, "approvals_reviewer"), - value: value(ApprovalsReviewer::GuardianSubagent.to_string()), - }); - } - edits.push(ConfigEdit::ClearPath { - segments: feature_scope_segments(scope, "smart_approvals"), - }); -} - -/// Rewrites the legacy `smart_approvals` feature flag to -/// `guardian_approval` in `config.toml` before normal config loading. -/// -/// If the old key is present, this preserves its value by setting -/// `guardian_approval = ` when the new key is not already present. -/// Because the deprecated flag historically meant "turn guardian review on", -/// this migration also backfills `approvals_reviewer = "guardian_subagent"` -/// in the same scope when that reviewer is not already configured there and the -/// migrated feature value is `true`. -/// In all cases it removes the deprecated `smart_approvals` entry so future -/// loads only see the canonical feature flag name. -async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result { - let config_path = codex_home.join(CONFIG_TOML_FILE); - if !tokio::fs::try_exists(&config_path).await? { - return Ok(false); - } - - let config_contents = tokio::fs::read_to_string(&config_path).await?; - let Ok(config_toml) = toml::from_str::(&config_contents) else { - return Ok(false); - }; - - let mut edits = Vec::new(); - - let root_scope = Vec::new(); - if let Some(features) = config_toml.features.as_ref() { - push_smart_approvals_alias_migration_edits( - &mut edits, - &root_scope, - features, - config_toml.approvals_reviewer.is_none(), - ); - } - - for (profile_name, profile) in &config_toml.profiles { - if let Some(features) = profile.features.as_ref() { - let scope = vec!["profiles".to_string(), profile_name.clone()]; - push_smart_approvals_alias_migration_edits( - &mut edits, - &scope, - features, - profile.approvals_reviewer.is_none(), - ); - } - } - - if edits.is_empty() { - return Ok(false); - } - - ConfigEditsBuilder::new(codex_home) - .with_edits(edits) - .apply() - .await - .map_err(|err| { - std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}")) - })?; - Ok(true) -} - impl Config { /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( @@ -868,9 +786,6 @@ pub async fn load_config_as_toml_with_cli_overrides( cwd: &AbsolutePathBuf, cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { - if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await { - tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias"); - } let config_layer_stack = load_config_layers_state( codex_home, Some(cwd.clone()), diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3b20779cd5..7a94700ce9 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -804,7 +804,12 @@ impl Notice { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SkillConfig { - pub path: AbsolutePathBuf, + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub enabled: bool, } diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/core/src/config/types_tests.rs index adb65e1673..dde0cd18fc 100644 --- a/codex-rs/core/src/config/types_tests.rs +++ b/codex-rs/core/src/config/types_tests.rs @@ -243,6 +243,47 @@ fn deserialize_server_config_with_tool_filters() { assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); } +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f990a80dce..57807e5f85 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -17,6 +17,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; @@ -204,9 +205,10 @@ impl ContextManager { } } - /// Drop the last `num_turns` user turns from this history. + /// Drop the last `num_turns` instruction turns from this history. /// - /// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`. + /// Instruction turns are history messages that should behave like a new prompt boundary: + /// ordinary user messages and structured assistant inter-agent instructions. /// /// This mirrors thread-rollback semantics: /// - `num_turns == 0` is a no-op @@ -248,7 +250,7 @@ impl ContextManager { } fn get_non_last_reasoning_items_tokens(&self) -> i64 { - // Get reasoning items excluding all the ones after the last user message. + // Get reasoning items excluding all the ones after the last instruction boundary. let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; @@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { return false; }; - role == "user" && !is_contextual_user_message_content(content) + (role == "user" && !is_contextual_user_message_content(content)) + || (role == "assistant" && is_inter_agent_instruction_content(content)) +} + +fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { + InterAgentCommunication::is_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 71b3aded0c..1a4dc0ed8e 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -4,6 +4,7 @@ use crate::truncate::TruncationPolicy; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_git::GhostCommit; +use codex_protocol::AgentPath; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -17,6 +18,7 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::InterAgentCommunication; use image::ImageBuffer; use image::ImageFormat; use image::Rgba; @@ -38,6 +40,24 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn inter_agent_assistant_msg(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + ); + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: serde_json::to_string(&communication).unwrap(), + }], + end_turn: None, + phase: None, + } +} + fn create_history_with_items(items: Vec) -> ContextManager { let mut h = ContextManager::new(); // Use a generous but fixed token budget; tests only rely on truncation @@ -225,6 +245,40 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item ); } +#[test] +fn inter_agent_assistant_messages_are_turn_boundaries() { + let item = inter_agent_assistant_msg("continue"); + + assert!(is_user_turn_boundary(&item)); +} + +#[test] +fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() { + let first_turn = user_input_text_msg("first"); + let first_reply = assistant_msg("done"); + let inter_agent_turn = inter_agent_assistant_msg("continue"); + let inter_agent_reply = assistant_msg("worker reply"); + let mut history = create_history_with_items(vec![ + first_turn.clone(), + first_reply.clone(), + inter_agent_turn, + inter_agent_reply, + ]); + + history.drop_last_n_user_turns(1); + + assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); +} + +#[test] +fn legacy_inter_agent_assistant_messages_are_not_turn_boundaries() { + let item = assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + + assert!(!is_user_turn_boundary(&item)); +} + #[test] fn total_token_usage_includes_all_items_after_last_model_generated_item() { let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 871cf502aa..c122187901 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -43,6 +43,7 @@ fn build_permissions_update_item( Some(DeveloperInstructions::from_policy( next.sandbox_policy.get(), next.approval_policy.value(), + next.config.approvals_reviewer, exec_policy, &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index ad776d1424..8f80547a4c 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -72,7 +72,7 @@ fn parse_agent_message( let mut content: Vec = Vec::new(); for content_item in message.iter() { match content_item { - ContentItem::OutputText { text } => { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { content.push(AgentMessageContent::Text { text: text.clone() }); } _ => { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 553550d74a..65c2e7d97d 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -95,6 +95,43 @@ fn skips_local_image_label_text() { } } +#[test] +fn parses_assistant_message_input_text_for_backward_compatibility() { + let item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string(), + }], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected assistant message turn item"); + + match turn_item { + TurnItem::AgentMessage(message) => { + let rendered = message + .content + .into_iter() + .map(|content| { + let AgentMessageContent::Text { text } = content; + text + }) + .collect::>(); + assert_eq!( + rendered, + vec![ + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" + .to_string() + ] + ); + } + other => panic!("expected TurnItem::AgentMessage, got {other:?}"), + } +} + #[test] fn skips_unnamed_image_label_text() { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index d6ec0bd3f2..f062bcc741 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -507,39 +507,25 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { #[tokio::test] async fn evaluates_bash_lc_inner_commands() { - let policy_src = r#" -prefix_rule(pattern=["rm"], decision="forbidden") -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let forbidden_script = vec![ - "bash".to_string(), - "-lc".to_string(), - "rm -rf /some/important/folder".to_string(), - ]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &forbidden_script, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="forbidden")"#.to_string()), + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "rm -rf /some/important/folder".to_string(), + ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string() - } - ); + }, + ExecApprovalRequirement::Forbidden { + reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string(), + }, + ) + .await; } #[test] @@ -562,163 +548,132 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { #[tokio::test] async fn evaluates_heredoc_script_against_prefix_rules() { - let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); let command = vec![ "bash".to_string(), "-lc".to_string(), "python3 <<'PY'\nprint('hello')\nPY".to_string(), ]; - let requirement = ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), + command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn omits_auto_amendment_for_heredoc_fallback_prompts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 <<'PY'\nprint('hello')\nPY".to_string(), - ]; - let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "python3 <<'PY'\nprint('hello')\nPY".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: Some(requested_prefix.clone()), - }) - .await; - - assert_eq!( - requirement, + prefix_rule: Some(vec![ + "python3".to_string(), + "-m".to_string(), + "pip".to_string(), + ]), + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn justification_is_included_in_forbidden_exec_approval_requirement() { - let policy_src = r#" + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some( + r#" prefix_rule( pattern=["rm"], decision="forbidden", justification="destructive command", ) -"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &[ +"# + .to_string(), + ), + command: vec![ "rm".to_string(), "-rf".to_string(), "/some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string() - } - ); + reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string(), + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_prefers_execpolicy_match() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + 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, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -731,31 +686,22 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) prefix_rule(pattern=["git"], decision="allow") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src), + command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] @@ -774,93 +720,71 @@ host_executable(name = "git", paths = ["{allowed_git_path_literal}"]) prefix_rule(pattern=["git"], decision="prompt") "# ); - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", &policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![disallowed_git_path, "status".to_string()]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + 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(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + disallowed_git_path, + "status".to_string(), + ])), + }, + ) + .await; } #[tokio::test] async fn requested_prefix_rule_can_approve_absolute_path_commands() { - let command = vec![ - host_program_path("cargo"), - "install".to_string(), - "cargo-insta".to_string(), - ]; - let manager = ExecPolicyManager::default(); - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + host_program_path("cargo"), + "install".to_string(), + "cargo-insta".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "cargo".to_string(), "install".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn exec_approval_requirement_respects_approval_policy() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + 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, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { - reason: PROMPT_CONFLICT_REASON.to_string() - } - ); + reason: PROMPT_CONFLICT_REASON.to_string(), + }, + ) + .await; } #[test] @@ -907,11 +831,10 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { - let command = vec!["madeup-cmd".to_string()]; - - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec!["madeup-cmd".to_string()], approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, @@ -919,19 +842,16 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Forbidden { reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), - } - ); + }, + ) + .await; } #[tokio::test] @@ -1243,178 +1163,140 @@ async fn append_execpolicy_amendment_rejects_empty_prefix() { async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { let command = vec!["cargo".to_string(), "build".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) - } - ); + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { - let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["rm".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + 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, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: Some("`rm` requires approval by policy".to_string()), proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "cargo build && echo ok".to_string(), - ]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "cargo".to_string(), - "build".to_string() + "build".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec![ "bash".to_string(), "-lc".to_string(), "cat && apple".to_string(), ]; - assert_eq!( - ExecPolicyManager::new(policy) - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }) - .await, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(policy_src.to_string()), + command, + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "apple".to_string() + "apple".to_string(), ])), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { let command = vec!["echo".to_string(), "safe".to_string()]; - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: false, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } #[tokio::test] async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { - let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let policy = Arc::new(parser.build()); - let command = vec!["echo".to_string(), "safe".to_string()]; - - let manager = ExecPolicyManager::new(policy); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), + command: vec!["echo".to_string(), "safe".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::Skip { bypass_sandbox: true, proposed_execpolicy_amendment: None, - } - ); + }, + ) + .await; } fn derive_requested_execpolicy_amendment_for_test( @@ -1564,25 +1446,23 @@ fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() { #[tokio::test] async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]); - let manager = ExecPolicyManager::default(); - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, + + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - }) - .await; - - assert_eq!( - requirement, + }, ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - } - ); + }, + ) + .await; } fn vec_str(items: &[&str]) -> Vec { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 34f0b6298e..50bf2ed843 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -514,6 +514,7 @@ async fn run_review_on_session( items: params.prompt_items.clone(), cwd: params.parent_turn.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: params.model.clone(), effort: params.reasoning_effort, @@ -577,6 +578,7 @@ async fn wait_for_guardian_review( ) -> (GuardianReviewSessionOutcome, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); + let mut last_error_message: Option = None; loop { tokio::select! { @@ -598,11 +600,22 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + if turn_complete.last_agent_message.is_none() + && let Some(error_message) = last_error_message + { + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), + true, + ); + } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, ); } + EventMsg::Error(error) => { + last_error_message = Some(error.message); + } EventMsg::TurnAborted(_) => { return (GuardianReviewSessionOutcome::Aborted, true); } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index e1595ea167..89c22528e8 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -31,6 +31,7 @@ use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -717,6 +718,100 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let error_message = + "Item 'rs_test' of type 'reasoning' was provided without its required following item."; + let _request_log = mount_response_once( + &server, + wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": error_message, + "type": "invalid_request_error", + "param": "input" + } + })), + ) + .await; + + let (mut session, mut turn, rx) = crate::codex::make_session_and_context_with_rx().await; + let mut config = (*turn.config).clone(); + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + config.user_instructions = None; + let config = Arc::new(config); + let models_manager = Arc::new(test_support::models_manager_with_provider( + config.codex_home.clone(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + Arc::get_mut(&mut session) + .expect("session should be uniquely owned") + .services + .models_manager = models_manager; + let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned"); + turn_mut.config = Arc::clone(&config); + turn_mut.provider = config.model_provider.clone(); + turn_mut.user_instructions = None; + + seed_guardian_parent_history(&session, &turn).await; + + let decision = review_approval_request( + &session, + &turn, + GuardianApprovalRequest::Shell { + id: "shell-guardian-error".to_string(), + command: vec!["git".to_string(), "push".to_string()], + cwd: PathBuf::from("/repo/codex-rs/core"), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Need to push the reviewed docs fix.".to_string()), + }, + None, + ) + .await; + + assert_eq!(decision, ReviewDecision::Denied); + + let mut warnings = Vec::new(); + let mut denial_rationales = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event.msg { + EventMsg::Warning(event) => warnings.push(event.message), + EventMsg::GuardianAssessment(event) + if event.status == GuardianAssessmentStatus::Denied => + { + denial_rationales.push(event.rationale) + } + _ => {} + } + } + + assert!( + warnings + .iter() + .any(|message| message.contains(error_message)), + "warning should include the underlying responses api error" + ); + assert!( + denial_rationales + .iter() + .flatten() + .any(|message| message.contains(error_message)), + "denial rationale should include the underlying responses api error" + ); + assert!( + denial_rationales.iter().flatten().all(|message| { + !message.contains("guardian review completed without an assessment payload") + }), + "denial rationale should not fall back to the generic missing payload error" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { let first_assessment = serde_json::json!({ diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 26b49facc3..7e6ecaca10 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PreToolUseOutcome; +use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; @@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks( .await } +pub(crate) async fn run_pre_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, +) -> Option { + let request = PreToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + 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), + tool_name: "Bash".to_string(), + tool_use_id, + command, + }; + let preview_runs = sess.hooks().preview_pre_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let PreToolUseOutcome { + hook_events, + should_block, + block_reason, + } = sess.hooks().run_pre_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, hook_events).await; + + if should_block { block_reason } else { None } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 223e51dd75..cd6b51b7e9 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -96,6 +96,7 @@ pub mod review_prompts; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; +pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; #[deprecated(note = "use ThreadManager")] diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index f02758d273..571535b5f0 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -19,7 +19,6 @@ use super::remote::fetch_remote_featured_plugin_ids; use super::remote::fetch_remote_plugin_status; use super::remote::uninstall_remote_plugin; use super::startup_sync::start_startup_remote_plugin_sync_once; -use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginIdError; use super::store::PluginInstallResult as StorePluginInstallResult; @@ -38,6 +37,9 @@ use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; use crate::config_loader::ConfigLayerStack; use crate::skills::SkillMetadata; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; @@ -152,6 +154,7 @@ pub struct PluginDetail { pub installed: bool, pub enabled: bool, pub skills: Vec, + pub disabled_skill_paths: HashSet, pub apps: Vec, pub mcp_server_names: Vec, } @@ -183,6 +186,8 @@ pub struct LoadedPlugin { pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, + pub disabled_skill_paths: HashSet, + pub has_enabled_skills: bool, pub mcp_servers: HashMap, pub apps: Vec, pub error: Option, @@ -235,7 +240,7 @@ impl PluginCapabilitySummary { .clone() .unwrap_or_else(|| plugin.config_name.clone()), description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), - has_skills: !plugin.skill_roots.is_empty(), + has_skills: plugin.has_enabled_skills, mcp_server_names, app_connector_ids: plugin.apps.clone(), }; @@ -258,11 +263,16 @@ impl PluginCapabilitySummary { impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { + let has_skills = value.skills.iter().any(|skill| { + !value + .disabled_skill_paths + .contains(&skill.path_to_skills_md) + }); Self { config_name: value.id, display_name: value.name, description: prompt_safe_plugin_description(value.description.as_deref()), - has_skills: !value.skills.is_empty(), + has_skills, mcp_server_names: value.mcp_server_names, app_connector_ids: value.apps, } @@ -531,7 +541,11 @@ impl PluginsManager { return outcome; } - let outcome = load_plugins_from_layer_stack(&config.config_layer_stack, &self.store); + let outcome = load_plugins_from_layer_stack( + &config.config_layer_stack, + &self.store, + self.restriction_product, + ); log_plugin_load_errors(&outcome); let mut cache = match self.cached_enabled_outcome.write() { Ok(cache) => cache, @@ -1070,6 +1084,11 @@ impl PluginsManager { let source_path = match &plugin.source { MarketplacePluginSource::Local { path } => path.clone(), }; + if !source_path.as_path().is_dir() { + return Err(MarketplaceError::InvalidPlugin( + "path does not exist or is not a directory".to_string(), + )); + } let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { MarketplaceError::InvalidPlugin( "missing or invalid .codex-plugin/plugin.json".to_string(), @@ -1077,15 +1096,13 @@ impl PluginsManager { })?; let description = manifest.description.clone(); let manifest_paths = &manifest.paths; - let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths); - let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot { - path, - scope: SkillScope::User, - })) - .skills - .into_iter() - .filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product)) - .collect(); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let resolved_skills = load_plugin_skills( + source_path.as_path(), + manifest_paths, + self.restriction_product, + &skill_config_rules, + ); let apps = load_plugin_apps(source_path.as_path()); let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths); let mut mcp_server_names = Vec::new(); @@ -1111,7 +1128,8 @@ impl PluginsManager { interface: plugin.interface, installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), - skills, + skills: resolved_skills.skills, + disabled_skill_paths: resolved_skills.disabled_skill_paths, apps, mcp_server_names, }, @@ -1347,7 +1365,9 @@ struct PluginAppConfig { pub(crate) fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, + restriction_product: Option, ) -> PluginLoadOutcome { + let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack) .into_iter() .collect(); @@ -1356,7 +1376,13 @@ pub(crate) fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store); + let loaded_plugin = load_plugin( + configured_name.clone(), + &plugin, + store, + restriction_product, + &skill_config_rules, + ); for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) @@ -1463,16 +1489,24 @@ fn configured_plugins_from_stack( } } -fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin { - let plugin_root = PluginId::parse(&config_name).map(|plugin_id| { - store - .active_plugin_root(&plugin_id) - .unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION)) - }); - let root = match &plugin_root { - Ok(plugin_root) => plugin_root.clone(), - Err(_) => store.root().clone(), - }; +fn load_plugin( + config_name: String, + plugin: &PluginConfig, + store: &PluginStore, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> LoadedPlugin { + let plugin_id = PluginId::parse(&config_name); + let active_plugin_root = plugin_id + .as_ref() + .ok() + .and_then(|plugin_id| store.active_plugin_root(plugin_id)); + let root = active_plugin_root + .clone() + .unwrap_or_else(|| match &plugin_id { + Ok(plugin_id) => store.plugin_base_root(plugin_id), + Err(_) => store.root().clone(), + }); let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, @@ -1480,6 +1514,8 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) root, enabled: plugin.enabled, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -1489,8 +1525,14 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) return loaded_plugin; } - let plugin_root = match plugin_root { - Ok(plugin_root) => plugin_root, + let plugin_root = match plugin_id { + Ok(_) => match active_plugin_root { + Some(plugin_root) => plugin_root, + None => { + loaded_plugin.error = Some("plugin is not installed".to_string()); + return loaded_plugin; + } + }, Err(err) => { loaded_plugin.error = Some(err.to_string()); return loaded_plugin; @@ -1511,6 +1553,15 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin.manifest_name = Some(manifest.name.clone()); loaded_plugin.manifest_description = manifest.description.clone(); loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths); + let resolved_skills = load_plugin_skills( + plugin_root.as_path(), + manifest_paths, + restriction_product, + skill_config_rules, + ); + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; let mut mcp_servers = HashMap::new(); for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path); @@ -1530,6 +1581,52 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin } +struct ResolvedPluginSkills { + skills: Vec, + disabled_skill_paths: HashSet, + had_errors: bool, +} + +impl ResolvedPluginSkills { + fn has_enabled_skills(&self) -> bool { + // Keep the plugin visible in capability summaries if skill loading was partial. + self.had_errors + || self + .skills + .iter() + .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + } +} + +fn load_plugin_skills( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> ResolvedPluginSkills { + let outcome = load_skills_from_roots( + plugin_skill_roots(plugin_root, manifest_paths) + .into_iter() + .map(|path| SkillRoot { + path, + scope: SkillScope::User, + }), + ); + let had_errors = !outcome.errors.is_empty(); + let skills = outcome + .skills + .into_iter() + .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) + .collect::>(); + let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); + + ResolvedPluginSkills { + skills, + disabled_skill_paths, + had_errors, + } +} + fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec { let mut paths = default_skill_roots(plugin_root); if let Some(path) = &manifest_paths.skills { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index cd8541a85c..630808dc0e 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -13,6 +13,7 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::protocol::Product; use pretty_assertions::assert_eq; use std::fs; use tempfile::TempDir; @@ -139,6 +140,8 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills")], + disabled_skill_paths: HashSet::new(), + has_enabled_skills: true, mcp_servers: HashMap::from([( "sample".to_string(), McpServerConfig { @@ -185,6 +188,89 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[test] +fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &skill_path, + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!( + outcome.plugins[0].disabled_skill_paths, + HashSet::from([skill_path]) + ); + assert!(!outcome.plugins[0].has_enabled_skills); + assert!(outcome.capability_summaries().is_empty()); +} + +#[test] +fn load_plugins_ignores_unknown_disabled_skill_names() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:missing-skill" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + + assert!(outcome.plugins[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins[0].has_enabled_skills); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); +} + #[test] fn plugin_telemetry_metadata_uses_default_mcp_config_path() { let codex_home = TempDir::new().unwrap(); @@ -540,6 +626,8 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -651,6 +739,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -664,6 +754,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let outcome = PluginLoadOutcome::from_plugins(vec![ LoadedPlugin { skill_roots: vec![codex_home.path().join("skills-plugin/skills")], + has_enabled_skills: true, ..plugin("skills@test", "skills-plugin", "skills-plugin") }, LoadedPlugin { @@ -1166,6 +1257,70 @@ enabled = true assert!(matches!(err, MarketplaceError::PluginsDisabled)); } +#[tokio::test] +async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("enabled-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"enabled-plugin"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + write_file( + &repo_root.join(".codex/config.toml"), + r#"[[skills.config]] +name = "enabled-plugin:sample-search" +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "enabled-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .unwrap(); + + assert!(outcome.plugin.disabled_skill_paths.is_empty()); +} + #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); @@ -2082,8 +2237,11 @@ fn load_plugins_ignores_project_config_files() { ) .expect("config layer stack should build"); - let outcome = - load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf())); + let outcome = load_plugins_from_layer_stack( + &stack, + &PluginStore::new(codex_home.path().to_path_buf()), + Some(Product::Codex), + ); assert_eq!(outcome, PluginLoadOutcome::default()); } diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 262ca10286..97b0ccefe1 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -110,10 +110,15 @@ impl PluginStore { .filter(|version| validate_plugin_segment(version, "plugin version").is_ok()) .collect::>(); discovered_versions.sort_unstable(); - if discovered_versions.len() == 1 { - discovered_versions.pop() - } else { + if discovered_versions.is_empty() { None + } else if discovered_versions + .iter() + .any(|version| version == DEFAULT_PLUGIN_VERSION) + { + Some(DEFAULT_PLUGIN_VERSION.to_string()) + } else { + discovered_versions.pop() } } diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index b1da11a8a6..61c3460255 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -130,6 +130,50 @@ fn active_plugin_version_reads_version_directory_name() { ); } +#[test] +fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "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("local".to_string()) + ); +} + +#[test] +fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/fedcba9876543210", + "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("fedcba9876543210".to_string()) + ); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 490bf42b97..fee84974fe 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec, +} + +pub(crate) fn skill_config_rules_from_stack( + config_layer_stack: &ConfigLayerStack, +) -> SkillConfigRules { + let mut entries = Vec::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(skills_value) = layer.config.get("skills") else { + continue; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + continue; + } + }; + + for entry in skills.config { + let Some(selector) = skill_config_rule_selector(&entry) else { + continue; + }; + // Preserve layer order so a later name selector can override an earlier path selector + // for the same loaded skill. + entries.retain(|entry: &SkillConfigRule| entry.selector != selector); + entries.push(SkillConfigRule { + selector, + enabled: entry.enabled, + }); + } + } + + SkillConfigRules { entries } +} + +pub(crate) fn resolve_disabled_skill_paths( + skills: &[SkillMetadata], + rules: &SkillConfigRules, +) -> HashSet { + let mut disabled_paths = HashSet::new(); + + for entry in &rules.entries { + match &entry.selector { + SkillConfigRuleSelector::Path(path) => { + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + SkillConfigRuleSelector::Name(name) => { + for path in skills + .iter() + .filter(|skill| skill.name == *name) + .map(|skill| skill.path_to_skills_md.clone()) + { + if entry.enabled { + disabled_paths.remove(&path); + } else { + disabled_paths.insert(path); + } + } + } + } + } + + disabled_paths +} + +fn skill_config_rule_selector(entry: &SkillConfig) -> Option { + match (entry.path.as_ref(), entry.name.as_deref()) { + (Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path( + path.as_path(), + ))), + (None, Some(name)) => { + let name = name.trim(); + if name.is_empty() { + warn!("ignoring empty skills.config name override"); + None + } else { + Some(SkillConfigRuleSelector::Name(name.to_string())) + } + } + (Some(_), Some(_)) => { + warn!("ignoring skills.config entry with both path and name selectors"); + None + } + (None, None) => { + warn!("ignoring skills.config entry without a path or name selector"); + None + } + } +} + +fn normalize_rule_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 982780f821..446580b6c7 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; -use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -16,12 +15,14 @@ use tracing::warn; use crate::config::Config; use crate::config::types::SkillsConfig; use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::plugins::PluginsManager; use crate::skills::SkillLoadOutcome; use crate::skills::build_implicit_skill_path_indexes; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use crate::skills::loader::skill_roots; @@ -81,15 +82,13 @@ impl SkillsManager { /// to share a directory. pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome { let roots = self.skill_roots_for_config(config); - let cache_key = config_skills_cache_key(&roots, &config.config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let cache_key = config_skills_cache_key(&roots, &skill_config_rules); if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { return outcome; } - let outcome = crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack), - self.restriction_product, - ); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_config .write() @@ -192,7 +191,8 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let outcome = self.build_skill_outcome(roots, &config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_cwd .write() @@ -204,12 +204,14 @@ impl SkillsManager { fn build_skill_outcome( &self, roots: Vec, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack), + let outcome = crate::skills::filter_skill_load_outcome_for_product( + load_skills_from_roots(roots), self.restriction_product, - ) + ); + let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); + finalize_skill_outcome(outcome, disabled_paths) } pub fn clear_cache(&self) { @@ -256,7 +258,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { roots: Vec<(PathBuf, u8)>, - disabled_paths: Vec, + skill_config_rules: SkillConfigRules, } pub(crate) fn bundled_skills_enabled_from_stack( @@ -281,53 +283,10 @@ pub(crate) fn bundled_skills_enabled_from_stack( skills.bundled.unwrap_or_default().enabled } -fn disabled_paths_from_stack( - config_layer_stack: &crate::config_loader::ConfigLayerStack, -) -> HashSet { - let mut configs = HashMap::new(); - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) { - if !matches!( - layer.name, - ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags - ) { - continue; - } - - let Some(skills_value) = layer.config.get("skills") else { - continue; - }; - let skills: SkillsConfig = match skills_value.clone().try_into() { - Ok(skills) => skills, - Err(err) => { - warn!("invalid skills config: {err}"); - continue; - } - }; - - for entry in skills.config { - let path = normalize_override_path(entry.path.as_path()); - configs.insert(path, entry.enabled); - } - } - - configs - .into_iter() - .filter_map(|(path, enabled)| (!enabled).then_some(path)) - .collect() -} - fn config_skills_cache_key( roots: &[SkillRoot], - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> ConfigSkillsCacheKey { - let mut disabled_paths: Vec = disabled_paths_from_stack(config_layer_stack) - .into_iter() - .collect(); - disabled_paths.sort_unstable(); - ConfigSkillsCacheKey { roots: roots .iter() @@ -341,15 +300,15 @@ fn config_skills_cache_key( (root.path.clone(), scope_rank) }) .collect(), - disabled_paths, + skill_config_rules: skill_config_rules.clone(), } } fn finalize_skill_outcome( mut outcome: SkillLoadOutcome, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + disabled_paths: HashSet, ) -> SkillLoadOutcome { - outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack); + outcome.disabled_paths = disabled_paths; let (by_scripts_dir, by_doc_path) = build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation()); outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir); @@ -357,10 +316,6 @@ fn finalize_skill_outcome( outcome } -fn normalize_override_path(path: &Path) -> PathBuf { - dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec { let mut normalized: Vec = extra_user_roots .iter() diff --git a/codex-rs/core/src/skills/manager_tests.rs b/codex-rs/core/src/skills/manager_tests.rs index cb3c48ed7f..d9e5859f4e 100644 --- a/codex-rs/core/src/skills/manager_tests.rs +++ b/codex-rs/core/src/skills/manager_tests.rs @@ -5,6 +5,10 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirementsToml; use crate::plugins::PluginsManager; +use crate::skills::SkillMetadata; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; +use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -17,6 +21,49 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s fs::write(skill_dir.join("SKILL.md"), content).unwrap(); } +fn write_plugin_skill( + codex_home: &TempDir, + marketplace: &str, + plugin_name: &str, + dir: &str, + name: &str, + description: &str, +) -> PathBuf { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace) + .join(plugin_name) + .join("local"); + let skill_dir = plugin_root.join("skills").join(dir); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write(&skill_path, content).unwrap(); + skill_path +} + +fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: path, + scope: SkillScope::User, + } +} + #[test] fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -68,6 +115,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn skills_for_config_disables_plugin_skills_by_name() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let skill_path = write_plugin_skill( + &codex_home, + "test", + "sample", + "sample-search", + "sample-search", + "search sample data", + ); + fs::write( + codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#, + ) + .expect("write config"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("load config"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + plugins_manager, + config.bundled_skills_enabled(), + ); + + let outcome = skills_manager.skills_for_config(&config); + let skill = outcome + .skills + .iter() + .find(|skill| skill.name == "sample:sample-search") + .expect("plugin skill should load"); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!(skill.path_to_skills_md, skill_path); + assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md)); + assert!( + !outcome + .allowed_skills_for_implicit_invocation() + .iter() + .any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md) + ); +} + #[tokio::test] async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -282,9 +391,10 @@ fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() { +fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -316,14 +426,19 @@ enabled = true ) .expect("valid config layer stack"); - assert_eq!(disabled_paths_from_stack(&stack), HashSet::new()); + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); } #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() { +fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -355,12 +470,88 @@ enabled = false ) .expect("valid config layer stack"); + let skill_config_rules = skill_config_rules_from_stack(&stack); assert_eq!( - disabled_paths_from_stack(&stack), + resolve_disabled_skill_paths(&[skill], &skill_config_rules), HashSet::from([skill_path]) ); } +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_disables_matching_name_selectors() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#, + ) + .expect("user layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::from([skill_path]) + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + )) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = true +"#, + ) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() { diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 4138ecbb86..ad76cd9da0 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod config_rules; mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap new file mode 100644 index 0000000000..90bb82d409 --- /dev/null +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -0,0 +1,17 @@ +--- +source: core/src/codex_tests.rs +assertion_line: 1282 +expression: snapshot +--- +Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. + +## First Forked Turn Request +00:message/developer: +01:message/user:> +02:message/user:fork seed +03:message/developer: +04:message/user:> +05:message/developer[2]: + [01] + [02] Fork turn collaboration instructions. +06:message/user:after fork diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b8e1d73b71..c737ad63a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -22,6 +22,7 @@ use tracing::warn; use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; +use crate::contextual_user_message::TURN_ABORTED_CLOSE_TAG; use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; @@ -60,6 +61,22 @@ pub(crate) use user_shell::execute_user_shell_command; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; +/// Shared model-visible marker used by both the real interrupt path and +/// interrupted fork snapshots. +pub(crate) fn interrupted_turn_history_marker() -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n{TURN_ABORTED_CLOSE_TAG}" + ), + }], + end_turn: None, + phase: None, + } +} + fn emit_turn_network_proxy_metric( session_telemetry: &SessionTelemetry, network_proxy_active: bool, @@ -153,7 +170,15 @@ impl Session { ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; self.clear_connector_selection().await; + self.start_task(turn_context, input, task).await; + } + async fn start_task( + self: &Arc, + turn_context: Arc, + input: Vec, + task: T, + ) { let task: Arc = Arc::new(task); let task_kind = task.kind(); let span_name = task.span_name(); @@ -224,6 +249,22 @@ impl Session { .await; } + pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc) { + if !self.has_queued_response_items_for_next_turn().await { + return; + } + + if self.active_turn.lock().await.is_some() { + return; + } + + let turn_context = self.new_default_turn().await; + self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + self.start_task(turn_context, Vec::new(), RegularTask::new()) + .await; + } + pub async fn abort_all_tasks(self: &Arc, reason: TurnAbortReason) { if let Some(mut active_turn) = self.take_active_turn().await { for task in active_turn.drain_tasks() { @@ -233,6 +274,9 @@ impl Session { // in-flight approval wait can surface as a model-visible rejection before TurnAborted. active_turn.clear_pending().await; } + if reason == TurnAbortReason::Interrupted { + self.ensure_task_for_queued_response_items().await; + } } pub async fn on_task_finished( @@ -371,10 +415,14 @@ impl Session { let mut turn = ActiveTurn::default(); let mut turn_state = turn.turn_state.lock().await; turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + for item in self.take_queued_response_items_for_next_turn().await { + turn_state.push_pending_input(item); + } drop(turn_state); turn.add_task(task); *active = Some(turn); } + async fn take_active_turn(&self) -> Option { let mut active = self.active_turn.lock().await; active.take() @@ -426,17 +474,7 @@ impl Session { if reason == TurnAbortReason::Interrupted { self.cleanup_after_interrupt(&task.turn_context).await; - let marker = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" - ), - }], - end_turn: None, - phase: None, - }; + let marker = interrupted_turn_history_marker(); self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) .await; self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index a63cf2cb94..642def5559 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -24,14 +24,21 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillsManager; +use crate::tasks::interrupted_turn_history_marker; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +#[cfg(test)] +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::W3cTraceContext; use futures::StreamExt; use futures::stream::FuturesUnordered; @@ -124,6 +131,45 @@ pub struct NewThread { pub session_configured: SessionConfiguredEvent, } +// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once +// core can represent sampling boundaries directly instead of relying on +// whichever items happened to be persisted mid-turn. +// +// Two likely future variants: +// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from +// the last stable model boundary without synthesizing an interrupt. +// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to +// fork after the next sampling boundary rather than interrupting immediately. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForkSnapshot { + /// Fork a committed prefix ending strictly before the nth user message. + /// + /// When `n` is within range, this cuts before that 0-based user-message + /// boundary. When `n` is out of range and the source thread is currently + /// mid-turn, this instead cuts before the active turn's opening boundary + /// so the fork drops the unfinished turn suffix. When `n` is out of range + /// and the source thread is already at a turn boundary, this returns the + /// full committed history unchanged. + TruncateBeforeNthUserMessage(usize), + + /// Fork the current persisted history as if the source thread had been + /// interrupted now. + /// + /// If the persisted snapshot ends mid-turn, this appends the same + /// `` marker produced by a real interrupt. If the snapshot is + /// already at a turn boundary, this returns the current persisted history + /// unchanged. + Interrupted, +} + +/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the +/// existing truncate-before-nth-user-message snapshot mode. +impl From for ForkSnapshot { + fn from(value: usize) -> Self { + Self::TruncateBeforeNthUserMessage(value) + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct ThreadShutdownReport { pub completed: Vec, @@ -539,20 +585,41 @@ impl ThreadManager { report } - /// Fork an existing thread by taking messages up to the given position (not including - /// the message at the given position) and starting a new thread with identical - /// configuration (unless overridden by the caller's `config`). The new thread will have - /// a fresh id. Pass `usize::MAX` to keep the full rollout history. - pub async fn fork_thread( + /// Fork an existing thread by snapshotting rollout history according to + /// `snapshot` and starting a new thread with identical configuration + /// (unless overridden by the caller's `config`). The new thread will have + /// a fresh id. + pub async fn fork_thread( &self, - nth_user_message: usize, + snapshot: S, config: Config, path: PathBuf, persist_extended_history: bool, parent_trace: Option, - ) -> CodexResult { + ) -> CodexResult + where + S: Into, + { + let snapshot = snapshot.into(); let history = RolloutRecorder::get_rollout_history(&path).await?; - let history = truncate_before_nth_user_message(history, nth_user_message); + let snapshot_state = snapshot_turn_state(&history); + let history = match snapshot { + ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => { + truncate_before_nth_user_message(history, nth_user_message, &snapshot_state) + } + ForkSnapshot::Interrupted => { + let history = match history { + InitialHistory::New => InitialHistory::New, + InitialHistory::Forked(history) => InitialHistory::Forked(history), + InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), + }; + if snapshot_state.ends_mid_turn { + append_interrupted_boundary(history, snapshot_state.active_turn_id) + } else { + history + } + } + }; Box::pin(self.state.spawn_thread( config, history, @@ -606,6 +673,17 @@ impl ThreadManagerState { thread.submit(op).await } + #[cfg(test)] + /// Append a prebuilt message to a thread by ID outside the normal user-input path. + pub(crate) async fn append_message( + &self, + thread_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let thread = self.get_thread(thread_id).await?; + thread.append_message(message).await + } + /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) @@ -825,11 +903,31 @@ impl ThreadManagerState { } } -/// Return a prefix of `items` obtained by cutting strictly before the nth user message -/// (0-based) and all items that follow it. -fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory { +/// Return a fork snapshot cut strictly before the nth user message (0-based). +/// +/// Out-of-range values keep the full committed history at a turn boundary, but +/// when the source thread is currently mid-turn they fall back to cutting +/// before the active turn's opening boundary so the fork omits the unfinished +/// suffix entirely. +fn truncate_before_nth_user_message( + history: InitialHistory, + n: usize, + snapshot_state: &SnapshotTurnState, +) -> InitialHistory { let items: Vec = history.get_rollout_items(); - let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n); + let user_positions = truncation::user_message_positions_in_rollout(&items); + let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() { + if let Some(cut_idx) = snapshot_state + .active_turn_start_index + .or_else(|| user_positions.last().copied()) + { + items[..cut_idx].to_vec() + } else { + items + } + } else { + truncation::truncate_rollout_before_nth_user_message_from_start(&items, n) + }; if rolled.is_empty() { InitialHistory::New @@ -838,6 +936,95 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia } } +#[derive(Debug, Eq, PartialEq)] +struct SnapshotTurnState { + ends_mid_turn: bool, + active_turn_id: Option, + active_turn_start_index: Option, +} + +fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { + let rollout_items = history.get_rollout_items(); + let mut builder = ThreadHistoryBuilder::new(); + for item in &rollout_items { + builder.handle_rollout_item(item); + } + let active_turn_id = builder.active_turn_id_if_explicit(); + if builder.has_active_turn() && active_turn_id.is_some() { + let active_turn_snapshot = builder.active_turn_snapshot(); + if active_turn_snapshot + .as_ref() + .is_some_and(|turn| turn.status != TurnStatus::InProgress) + { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + } + + return SnapshotTurnState { + ends_mid_turn: true, + active_turn_id, + active_turn_start_index: builder.active_turn_start_index(), + }; + } + + let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items) + .last() + .copied() + else { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + }; + + // Synthetic fork/resume histories can contain user/assistant response items + // without explicit turn lifecycle events. If the persisted snapshot has no + // terminating boundary after its last user message, treat it as mid-turn. + SnapshotTurnState { + ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) + ) + }), + active_turn_id: None, + active_turn_start_index: None, + } +} + +/// Append the same persisted interrupt boundary used by the live interrupt path +/// to an existing fork snapshot after the source thread has been confirmed to +/// be mid-turn. +fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) -> InitialHistory { + let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason: TurnAbortReason::Interrupted, + })); + + match history { + InitialHistory::New => InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + aborted_event, + ]), + InitialHistory::Forked(mut history) => { + history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + history.push(aborted_event); + InitialHistory::Forked(history) + } + InitialHistory::Resumed(mut resumed) => { + resumed + .history + .push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + resumed.history.push(aborted_event); + InitialHistory::Forked(resumed.history) + } + } +} + #[cfg(test)] #[path = "thread_manager_tests.rs"] mod tests; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e69e88fe73..40e7439ccc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -3,11 +3,15 @@ use crate::codex::make_session_and_context; use crate::config::test_config; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::models_manager::manager::RefreshStrategy; -use assert_matches::assert_matches; +use crate::rollout::RolloutRecorder; +use crate::tasks::interrupted_turn_history_marker; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::time::Duration; @@ -38,7 +42,7 @@ fn assistant_msg(text: &str) -> ResponseItem { } #[test] -fn drops_from_last_user_only() { +fn truncates_before_requested_user_message() { let items = [ user_msg("u1"), assistant_msg("a1"), @@ -68,7 +72,15 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(initial), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected_items = vec![ RolloutItem::ResponseItem(items[0].clone()), @@ -85,8 +97,99 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); - assert_matches!(truncated2, InitialHistory::New); + let truncated2 = truncate_before_nth_user_message( + InitialHistory::Forked(initial2.clone()), + 2, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + assert_eq!( + serde_json::to_value(truncated2.get_rollout_items()).unwrap(), + serde_json::to_value(initial2).unwrap() + ); +} + +#[test] +fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); +} + +#[test] +fn fork_thread_accepts_legacy_usize_snapshot_argument() { + fn assert_legacy_snapshot_callsite( + manager: &ThreadManager, + config: Config, + path: std::path::PathBuf, + ) { + let _future = manager.fork_thread( + usize::MAX, + config, + path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ); + } + + let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite; +} + +#[test] +fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone())); + assert_eq!( + snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-2".to_string()), + active_turn_start_index: Some(2), + }, + ); + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &snapshot_state, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); } #[tokio::test] @@ -104,7 +207,15 @@ async fn ignores_session_prefix_messages_when_truncating() { .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(rollout_items), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected: Vec = vec![ @@ -185,3 +296,421 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let _ = manager.list_models(RefreshStrategy::Online).await; assert_eq!(models_mock.requests().len(), 1); } + +#[test] +fn interrupted_fork_snapshot_appends_interrupt_boundary() { + let committed_history = + InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]); + + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted fork history"), + ); + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted empty fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted empty history"), + ); +} + +#[test] +fn interrupted_snapshot_is_not_mid_turn() { + let interrupted_history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + })), + ]); + + assert_eq!( + snapshot_turn_state(&interrupted_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +fn completed_legacy_event_history_is_not_mid_turn() { + let completed_history = InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "done".to_string(), + phase: None, + memory_citation: None, + })), + ]); + + assert_eq!( + snapshot_turn_state(&completed_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +fn mixed_response_and_legacy_user_event_history_is_mid_turn() { + let mixed_history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + ]); + + assert_eq!( + snapshot_turn_state(&mixed_history), + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from completed history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert!(source_snapshot_state.ends_mid_turn); + let expected_turn_id = source_snapshot_state.active_turn_id.clone(); + assert_eq!(expected_turn_id, None); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg( + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: expected_turn_id, + reason: TurnAbortReason::Interrupted, + }), + )) + .expect("serialize interrupted abort event"); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_abort_json + }) + .count(), + 1, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-explicit".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from explicit partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert_eq!( + source_snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-explicit".to_string()), + active_turn_start_index: Some(1), + }, + ); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert!(rollout_items.iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_id), + reason: TurnAbortReason::Interrupted, + })) if turn_id == "turn-explicit" + ) + })); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + assert!(snapshot_turn_state(&source_history).ends_mid_turn); + manager.remove_thread(&source.thread_id).await; + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config.clone(), + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + + let forked_rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + assert_eq!( + forked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + + manager.remove_thread(&forked.thread_id).await; + let reforked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + forked_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("re-fork interrupted snapshot"); + let reforked_path = reforked + .thread + .rollout_path() + .expect("re-forked rollout path should exist"); + let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path) + .await + .expect("read re-forked rollout history"); + let reforked_rollout_items: Vec<_> = reforked_history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize re-forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + .. + })) + ) + }) + .count(), + 1, + ); +} diff --git a/codex-rs/core/src/tools/code_mode/bridge.js b/codex-rs/core/src/tools/code_mode/bridge.js deleted file mode 100644 index 0c61a9db19..0000000000 --- a/codex-rs/core/src/tools/code_mode/bridge.js +++ /dev/null @@ -1,51 +0,0 @@ -const __codexContentItems = Array.isArray(globalThis.__codexContentItems) - ? globalThis.__codexContentItems - : []; -const __codexRuntime = globalThis.__codexRuntime; - -delete globalThis.__codexRuntime; - -Object.defineProperty(globalThis, '__codexContentItems', { - value: __codexContentItems, - configurable: true, - enumerable: false, - writable: false, -}); - -(() => { - if (!__codexRuntime || typeof __codexRuntime !== 'object') { - throw new Error('code mode runtime is unavailable'); - } - - function defineGlobal(name, value) { - Object.defineProperty(globalThis, name, { - value, - configurable: true, - enumerable: true, - writable: false, - }); - } - - defineGlobal('ALL_TOOLS', __codexRuntime.ALL_TOOLS); - defineGlobal('exit', __codexRuntime.exit); - defineGlobal('image', __codexRuntime.image); - defineGlobal('load', __codexRuntime.load); - defineGlobal('notify', __codexRuntime.notify); - defineGlobal('store', __codexRuntime.store); - defineGlobal('text', __codexRuntime.text); - defineGlobal('tools', __codexRuntime.tools); - defineGlobal('yield_control', __codexRuntime.yield_control); - - defineGlobal( - 'console', - Object.freeze({ - log() {}, - info() {}, - warn() {}, - error() {}, - debug() {}, - }) - ); -})(); - -__CODE_MODE_USER_CODE_PLACEHOLDER__ diff --git a/codex-rs/core/src/tools/code_mode/description.md b/codex-rs/core/src/tools/code_mode/description.md deleted file mode 100644 index e0a124c65f..0000000000 --- a/codex-rs/core/src/tools/code_mode/description.md +++ /dev/null @@ -1,19 +0,0 @@ -## exec -- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). -- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. -- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. -- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. -- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. -- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. -- Tool methods take either string or object as parameter. -- They return either a structured value or a string based on the description above. - -- 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 and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. -- `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(...)`. -- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. -- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running. 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 9eba126dd1..3f77216c16 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -1,8 +1,5 @@ use async_trait::async_trait; -use serde::Deserialize; -use crate::codex::Session; -use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -10,180 +7,52 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CODE_MODE_PRAGMA_PREFIX; -use super::CodeModeSessionProgress; use super::ExecContext; use super::PUBLIC_TOOL_NAME; use super::build_enabled_tools; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; -use super::protocol::build_source; +use super::handle_runtime_response; pub struct CodeModeExecuteHandler; -const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; - -#[derive(Debug, Default, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct CodeModeExecPragma { - #[serde(default)] - yield_time_ms: Option, - #[serde(default)] - max_output_tokens: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct CodeModeExecArgs { - code: String, - yield_time_ms: Option, - max_output_tokens: Option, -} impl CodeModeExecuteHandler { async fn execute( &self, - session: std::sync::Arc, - turn: std::sync::Arc, + session: std::sync::Arc, + turn: std::sync::Arc, call_id: String, code: String, ) -> Result { - let args = parse_freeform_args(&code)?; + let args = + codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?; let exec = ExecContext { session, turn }; let enabled_tools = build_enabled_tools(&exec).await; - let service = &exec.session.services.code_mode_service; - let stored_values = service.stored_values().await; - let source = - build_source(&args.code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; - let cell_id = service.allocate_cell_id().await; - let request_id = service.allocate_request_id().await; - let process_slot = service - .ensure_started() - .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let stored_values = exec + .session + .services + .code_mode_service + .stored_values() + .await; let started_at = std::time::Instant::now(); - let message = HostToNodeMessage::Start { - request_id: request_id.clone(), - cell_id: cell_id.clone(), - tool_call_id: call_id, - default_yield_time_ms: super::DEFAULT_EXEC_YIELD_TIME_MS, - enabled_tools, - stored_values, - source, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - }; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, cell_id, message, /*poll_max_output_tokens*/ None, started_at, - ) + let response = exec + .session + .services + .code_mode_service + .execute(codex_code_mode::ExecuteRequest { + tool_call_id: call_id, + enabled_tools, + source: args.code, + stored_values, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + }) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_output_tokens, started_at) + .await + .map_err(FunctionCallError::RespondToModel) } } -fn parse_freeform_args(input: &str) -> Result { - if input.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), - )); - } - - let mut args = CodeModeExecArgs { - code: input.to_string(), - yield_time_ms: None, - max_output_tokens: None, - }; - - let mut lines = input.splitn(2, '\n'); - let first_line = lines.next().unwrap_or_default(); - let rest = lines.next().unwrap_or_default(); - let trimmed = first_line.trim_start(); - let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { - return Ok(args); - }; - - if rest.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), - )); - } - - let directive = pragma.trim(); - if directive.is_empty() { - return Err(FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - )); - } - - let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" - )) - })?; - let object = value.as_object().ok_or_else(|| { - FunctionCallError::RespondToModel( - "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" - .to_string(), - ) - })?; - for key in object.keys() { - match key.as_str() { - "yield_time_ms" | "max_output_tokens" => {} - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" - ))); - } - } - } - - let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" - )) - })?; - if pragma - .yield_time_ms - .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) - { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), - )); - } - if pragma.max_output_tokens.is_some_and(|max_output_tokens| { - u64::try_from(max_output_tokens) - .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) - .unwrap_or(true) - }) { - return Err(FunctionCallError::RespondToModel( - "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), - )); - } - args.code = rest.to_string(); - args.yield_time_ms = pragma.yield_time_ms; - args.max_output_tokens = pragma.max_output_tokens; - Ok(args) -} - #[async_trait] impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; @@ -216,7 +85,3 @@ impl ToolHandler for CodeModeExecuteHandler { } } } - -#[cfg(test)] -#[path = "execute_handler_tests.rs"] -mod execute_handler_tests; diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index c8e1e0c165..a4838d2463 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -1,15 +1,18 @@ mod execute_handler; -mod process; -mod protocol; -mod service; +mod response_adapter; mod wait_handler; -mod worker; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_code_mode::CodeModeTurnHost; +use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; use crate::client_common::tools::ToolSpec; use crate::codex::Session; @@ -17,9 +20,8 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::ToolRouter; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; -use crate::tools::code_mode_description::code_mode_tool_reference; -use crate::tools::code_mode_description::normalize_code_mode_identifier; use crate::tools::context::FunctionToolOutput; +use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; @@ -29,180 +31,202 @@ use crate::truncate::TruncationPolicy; use crate::truncate::formatted_truncate_text_content_items_with_policy; use crate::truncate::truncate_function_output_items_with_policy; use crate::unified_exec::resolve_max_tokens; +use codex_features::Feature; -const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs"); -const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); -const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md"); -const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md"); -const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +pub(crate) use execute_handler::CodeModeExecuteHandler; +use response_adapter::into_function_call_output_content_items; +pub(crate) use wait_handler::CodeModeWaitHandler; -pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; -pub(crate) const WAIT_TOOL_NAME: &str = "wait"; - -pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool { - tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME -} -pub(crate) const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; -pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME; +pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME; +pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS; #[derive(Clone)] -pub(super) struct ExecContext { +pub(crate) struct ExecContext { pub(super) session: Arc, pub(super) turn: Arc, } -pub(crate) use execute_handler::CodeModeExecuteHandler; -pub(crate) use service::CodeModeService; -pub(crate) use wait_handler::CodeModeWaitHandler; - -enum CodeModeSessionProgress { - Finished(FunctionToolOutput), - Yielded { output: FunctionToolOutput }, +pub(crate) struct CodeModeService { + inner: codex_code_mode::CodeModeService, } -enum CodeModeExecutionStatus { - Completed, - Failed, - Running(String), - Terminated, -} - -pub(crate) fn tool_description(enabled_tools: &[(String, String)], code_mode_only: bool) -> String { - let description_template = CODE_MODE_DESCRIPTION_TEMPLATE.trim_end(); - if !code_mode_only { - return description_template.to_string(); +impl CodeModeService { + pub(crate) fn new(_js_repl_node_path: Option) -> Self { + Self { + inner: codex_code_mode::CodeModeService::new(), + } } - let mut sections = vec![ - CODE_MODE_ONLY_PREFACE.to_string(), - description_template.to_string(), - ]; + pub(crate) async fn stored_values(&self) -> std::collections::HashMap { + self.inner.stored_values().await + } - if !enabled_tools.is_empty() { - let nested_tool_reference = enabled_tools - .iter() - .map(|(name, nested_description)| { - let global_name = normalize_code_mode_identifier(name); - format!( - "### `{global_name}` (`{name}`)\n{}", - nested_description.trim() - ) + pub(crate) async fn replace_stored_values( + &self, + values: std::collections::HashMap, + ) { + self.inner.replace_stored_values(values).await; + } + + pub(crate) async fn execute( + &self, + request: codex_code_mode::ExecuteRequest, + ) -> Result { + self.inner.execute(request).await + } + + pub(crate) async fn wait( + &self, + request: codex_code_mode::WaitRequest, + ) -> Result { + self.inner.wait(request).await + } + + pub(crate) async fn start_turn_worker( + &self, + session: &Arc, + turn: &Arc, + router: Arc, + tracker: SharedTurnDiffTracker, + ) -> Option { + if !turn.features.enabled(Feature::CodeMode) { + return None; + } + + let exec = ExecContext { + session: Arc::clone(session), + turn: Arc::clone(turn), + }; + let tool_runtime = + ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); + let host = Arc::new(CoreTurnHost { exec, tool_runtime }); + Some(self.inner.start_turn_worker(host)) + } +} + +struct CoreTurnHost { + exec: ExecContext, + tool_runtime: ToolCallRuntime, +} + +#[async_trait::async_trait] +impl CodeModeTurnHost for CoreTurnHost { + async fn invoke_tool( + &self, + tool_name: String, + input: Option, + cancellation_token: CancellationToken, + ) -> Result { + call_nested_tool( + self.exec.clone(), + self.tool_runtime.clone(), + tool_name, + input, + cancellation_token, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + self.exec + .session + .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { + call_id, + name: Some(PUBLIC_TOOL_NAME.to_string()), + output: FunctionCallOutputPayload::from_text(text), + }]) + .await + .map_err(|_| { + format!("failed to inject exec notify message for cell {cell_id}: no active turn") }) - .collect::>() - .join("\n\n"); - sections.push(nested_tool_reference); } - - sections.join("\n\n") } -pub(crate) fn wait_tool_description() -> &'static str { - CODE_MODE_WAIT_DESCRIPTION_TEMPLATE -} - -async fn handle_node_message( +pub(super) async fn handle_runtime_response( exec: &ExecContext, - cell_id: String, - message: protocol::NodeToHostMessage, - poll_max_output_tokens: Option>, + response: RuntimeResponse, + max_output_tokens: Option, started_at: std::time::Instant, -) -> Result { - match message { - protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()), - protocol::NodeToHostMessage::Notify { .. } => Err(format!( - "unexpected {PUBLIC_TOOL_NAME} notify message in response path" - )), - protocol::NodeToHostMessage::Yielded { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Running(cell_id), - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Yielded { - output: FunctionToolOutput::from_content(delta_items, Some(true)), - }) +) -> Result { + let script_status = format_script_status(&response); + + match response { + RuntimeResponse::Yielded { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Terminated { content_items, .. } => { - let mut delta_items = output_content_items_from_json_values(content_items)?; - delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten()); - prepend_script_status( - &mut delta_items, - CodeModeExecutionStatus::Terminated, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(true)), - )) + RuntimeResponse::Terminated { content_items, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content(content_items, Some(true))) } - protocol::NodeToHostMessage::Result { + RuntimeResponse::Result { content_items, stored_values, error_text, - max_output_tokens_per_exec_call, .. } => { + let mut content_items = into_function_call_output_content_items(content_items); exec.session .services .code_mode_service .replace_stored_values(stored_values) .await; - let mut delta_items = output_content_items_from_json_values(content_items)?; let success = error_text.is_none(); if let Some(error_text) = error_text { - delta_items.push(FunctionCallOutputContentItem::InputText { + content_items.push(FunctionCallOutputContentItem::InputText { text: format!("Script error:\n{error_text}"), }); } - - let mut delta_items = truncate_code_mode_result( - delta_items, - poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call), - ); - prepend_script_status( - &mut delta_items, - if success { - CodeModeExecutionStatus::Completed - } else { - CodeModeExecutionStatus::Failed - }, - started_at.elapsed(), - ); - Ok(CodeModeSessionProgress::Finished( - FunctionToolOutput::from_content(delta_items, Some(success)), + content_items = truncate_code_mode_result(content_items, max_output_tokens); + prepend_script_status(&mut content_items, &script_status, started_at.elapsed()); + Ok(FunctionToolOutput::from_content( + content_items, + Some(success), )) } } } +fn format_script_status(response: &RuntimeResponse) -> String { + match response { + RuntimeResponse::Yielded { cell_id, .. } => { + format!("Script running with cell ID {cell_id}") + } + RuntimeResponse::Terminated { .. } => "Script terminated".to_string(), + RuntimeResponse::Result { error_text, .. } => { + if error_text.is_none() { + "Script completed".to_string() + } else { + "Script failed".to_string() + } + } + } +} + fn prepend_script_status( content_items: &mut Vec, - status: CodeModeExecutionStatus, + status: &str, wall_time: Duration, ) { let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0; - let header = format!( - "{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n", - match status { - CodeModeExecutionStatus::Completed => "Script completed".to_string(), - CodeModeExecutionStatus::Failed => "Script failed".to_string(), - CodeModeExecutionStatus::Running(cell_id) => { - format!("Script running with cell ID {cell_id}") - } - CodeModeExecutionStatus::Terminated => "Script terminated".to_string(), - } - ); + let header = format!("{status}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n"); content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header }); } fn truncate_code_mode_result( items: Vec, - max_output_tokens_per_exec_call: Option, + max_output_tokens: Option, ) -> Vec { - let max_output_tokens = resolve_max_tokens(max_output_tokens_per_exec_call); + let max_output_tokens = resolve_max_tokens(max_output_tokens); let policy = TruncationPolicy::Tokens(max_output_tokens); if items .iter() @@ -216,21 +240,9 @@ fn truncate_code_mode_result( truncate_function_output_items_with_policy(&items, policy) } -fn output_content_items_from_json_values( - content_items: Vec, -) -> Result, String> { - content_items - .into_iter() - .enumerate() - .map(|(index, item)| { - serde_json::from_value(item).map_err(|err| { - format!("invalid {PUBLIC_TOOL_NAME} content item at index {index}: {err}") - }) - }) - .collect() -} - -async fn build_enabled_tools(exec: &ExecContext) -> Vec { +pub(super) async fn build_enabled_tools( + exec: &ExecContext, +) -> Vec { let router = build_nested_router(exec).await; let mut out = router .specs() @@ -238,39 +250,37 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec { .map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true)) .filter_map(enabled_tool_from_spec) .collect::>(); - out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); - out.dedup_by(|left, right| left.tool_name == right.tool_name); + out.sort_by(|left, right| left.name.cmp(&right.name)); + out.dedup_by(|left, right| left.name == right.name); out } -fn enabled_tool_from_spec(spec: ToolSpec) -> Option { +fn enabled_tool_from_spec(spec: ToolSpec) -> Option { let tool_name = spec.name().to_string(); - if !is_code_mode_nested_tool(&tool_name) { + if !codex_code_mode::is_code_mode_nested_tool(&tool_name) { return None; } - let reference = code_mode_tool_reference(&tool_name); - let global_name = normalize_code_mode_identifier(&tool_name); - let (description, kind) = match spec { - ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function), - ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform), + match spec { + ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Function, + input_schema: serde_json::to_value(&tool.parameters).ok(), + output_schema: tool.output_schema, + }), + ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition { + name: tool_name, + description: tool.description, + kind: codex_code_mode::CodeModeToolKind::Freeform, + input_schema: None, + output_schema: None, + }), ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::ToolSearch { .. } - | ToolSpec::WebSearch { .. } => { - return None; - } - }; - - Some(protocol::EnabledTool { - tool_name, - global_name, - module_path: reference.module_path, - namespace: reference.namespace, - name: normalize_code_mode_identifier(&reference.tool_key), - description, - kind, - }) + | ToolSpec::WebSearch { .. } => None, + } } async fn build_nested_router(exec: &ExecContext) -> ToolRouter { @@ -303,7 +313,7 @@ async fn call_nested_tool( tool_runtime: ToolCallRuntime, tool_name: String, input: Option, - cancellation_token: tokio_util::sync::CancellationToken, + cancellation_token: CancellationToken, ) -> Result { if tool_name == PUBLIC_TOOL_NAME { return Err(FunctionCallError::RespondToModel(format!( @@ -340,18 +350,18 @@ async fn call_nested_tool( Ok(result.code_mode_result()) } -fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind { +fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind { if matches!(spec, ToolSpec::Freeform(_)) { - protocol::CodeModeToolKind::Freeform + codex_code_mode::CodeModeToolKind::Freeform } else { - protocol::CodeModeToolKind::Function + codex_code_mode::CodeModeToolKind::Function } } fn tool_kind_for_name( spec: Option, tool_name: &str, -) -> Result { +) -> Result { spec.as_ref() .map(tool_kind_for_spec) .ok_or_else(|| format!("tool `{tool_name}` is not enabled in {PUBLIC_TOOL_NAME}")) @@ -364,8 +374,12 @@ fn build_nested_tool_payload( ) -> Result { let actual_kind = tool_kind_for_name(spec, tool_name)?; match actual_kind { - protocol::CodeModeToolKind::Function => build_function_tool_payload(tool_name, input), - protocol::CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input), + codex_code_mode::CodeModeToolKind::Function => { + build_function_tool_payload(tool_name, input) + } + codex_code_mode::CodeModeToolKind::Freeform => { + build_freeform_tool_payload(tool_name, input) + } } } diff --git a/codex-rs/core/src/tools/code_mode/process.rs b/codex-rs/core/src/tools/code_mode/process.rs deleted file mode 100644 index 6dd6cde3ae..0000000000 --- a/codex-rs/core/src/tools/code_mode/process.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::sync::Mutex; -use tokio::sync::mpsc; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tracing::warn; - -use super::CODE_MODE_RUNNER_SOURCE; -use super::PUBLIC_TOOL_NAME; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use super::protocol::message_request_id; - -pub(super) struct CodeModeProcess { - pub(super) child: tokio::process::Child, - pub(super) stdin: Arc>, - pub(super) stdout_task: JoinHandle<()>, - pub(super) response_waiters: Arc>>>, - pub(super) message_rx: Arc>>, -} - -impl CodeModeProcess { - pub(super) async fn send( - &mut self, - request_id: &str, - message: &HostToNodeMessage, - ) -> Result { - if self.stdout_task.is_finished() { - return Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))); - } - - let (tx, rx) = oneshot::channel(); - self.response_waiters - .lock() - .await - .insert(request_id.to_string(), tx); - if let Err(err) = write_message(&self.stdin, message).await { - self.response_waiters.lock().await.remove(request_id); - return Err(err); - } - - match rx.await { - Ok(message) => Ok(message), - Err(_) => Err(std::io::Error::other(format!( - "{PUBLIC_TOOL_NAME} runner is not available" - ))), - } - } - - pub(super) fn has_exited(&mut self) -> Result { - self.child - .try_wait() - .map(|status| status.is_some()) - .map_err(std::io::Error::other) - } -} - -pub(super) async fn spawn_code_mode_process( - node_path: &std::path::Path, -) -> Result { - let mut cmd = tokio::process::Command::new(node_path); - cmd.arg("--experimental-vm-modules"); - cmd.arg("--eval"); - cmd.arg(CODE_MODE_RUNNER_SOURCE); - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd.spawn().map_err(std::io::Error::other)?; - let stdout = child.stdout.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stderr")) - })?; - let stdin = child - .stdin - .take() - .ok_or_else(|| std::io::Error::other(format!("{PUBLIC_TOOL_NAME} runner missing stdin")))?; - let stdin = Arc::new(Mutex::new(stdin)); - let response_waiters = Arc::new(Mutex::new(HashMap::< - String, - oneshot::Sender, - >::new())); - let (message_tx, message_rx) = mpsc::unbounded_channel(); - - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf).await { - Ok(_) => { - let stderr = String::from_utf8_lossy(&buf).trim().to_string(); - if !stderr.is_empty() { - warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}"); - } - } - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}"); - } - } - }); - let stdout_task = tokio::spawn({ - let response_waiters = Arc::clone(&response_waiters); - async move { - let mut stdout_lines = BufReader::new(stdout).lines(); - loop { - let line = match stdout_lines.next_line().await { - Ok(line) => line, - Err(err) => { - warn!("failed to read {PUBLIC_TOOL_NAME} stdout: {err}"); - break; - } - }; - let Some(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - let message: NodeToHostMessage = match serde_json::from_str(&line) { - Ok(message) => message, - Err(err) => { - warn!("failed to parse {PUBLIC_TOOL_NAME} stdout message: {err}"); - break; - } - }; - match message { - message @ (NodeToHostMessage::ToolCall { .. } - | NodeToHostMessage::Notify { .. }) => { - let _ = message_tx.send(message); - } - message => { - if let Some(request_id) = message_request_id(&message) - && let Some(waiter) = response_waiters.lock().await.remove(request_id) - { - let _ = waiter.send(message); - } - } - } - } - response_waiters.lock().await.clear(); - } - }); - - Ok(CodeModeProcess { - child, - stdin, - stdout_task, - response_waiters, - message_rx: Arc::new(Mutex::new(message_rx)), - }) -} - -pub(super) async fn write_message( - stdin: &Arc>, - message: &HostToNodeMessage, -) -> Result<(), std::io::Error> { - let line = serde_json::to_string(message).map_err(std::io::Error::other)?; - let mut stdin = stdin.lock().await; - stdin.write_all(line.as_bytes()).await?; - stdin.write_all(b"\n").await?; - stdin.flush().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/code_mode/protocol.rs b/codex-rs/core/src/tools/code_mode/protocol.rs deleted file mode 100644 index 2e72e1229c..0000000000 --- a/codex-rs/core/src/tools/code_mode/protocol.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::collections::HashMap; - -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; - -use super::CODE_MODE_BRIDGE_SOURCE; -use super::PUBLIC_TOOL_NAME; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(super) enum CodeModeToolKind { - Function, - Freeform, -} - -#[derive(Clone, Debug, Serialize)] -pub(super) struct EnabledTool { - pub(super) tool_name: String, - pub(super) global_name: String, - #[serde(rename = "module")] - pub(super) module_path: String, - pub(super) namespace: Vec, - pub(super) name: String, - pub(super) description: String, - pub(super) kind: CodeModeToolKind, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(super) struct CodeModeToolCall { - pub(super) request_id: String, - pub(super) id: String, - pub(super) name: String, - #[serde(default)] - pub(super) input: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub(super) struct CodeModeNotify { - pub(super) cell_id: String, - pub(super) call_id: String, - pub(super) text: String, -} - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum HostToNodeMessage { - Start { - request_id: String, - cell_id: String, - tool_call_id: String, - default_yield_time_ms: u64, - enabled_tools: Vec, - stored_values: HashMap, - source: String, - yield_time_ms: Option, - max_output_tokens: Option, - }, - Poll { - request_id: String, - cell_id: String, - yield_time_ms: u64, - }, - Terminate { - request_id: String, - cell_id: String, - }, - Response { - request_id: String, - id: String, - code_mode_result: JsonValue, - #[serde(default)] - error_text: Option, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum NodeToHostMessage { - ToolCall { - #[serde(flatten)] - tool_call: CodeModeToolCall, - }, - Yielded { - request_id: String, - content_items: Vec, - }, - Terminated { - request_id: String, - content_items: Vec, - }, - Notify { - #[serde(flatten)] - notify: CodeModeNotify, - }, - Result { - request_id: String, - content_items: Vec, - stored_values: HashMap, - #[serde(default)] - error_text: Option, - #[serde(default)] - max_output_tokens_per_exec_call: Option, - }, -} - -pub(super) fn build_source( - user_code: &str, - enabled_tools: &[EnabledTool], -) -> Result { - let enabled_tools_json = serde_json::to_string(enabled_tools) - .map_err(|err| format!("failed to serialize enabled tools: {err}"))?; - Ok(CODE_MODE_BRIDGE_SOURCE - .replace( - "__CODE_MODE_ENABLED_TOOLS_PLACEHOLDER__", - &enabled_tools_json, - ) - .replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code)) -} - -pub(super) fn message_request_id(message: &NodeToHostMessage) -> Option<&str> { - match message { - NodeToHostMessage::ToolCall { .. } => None, - NodeToHostMessage::Yielded { request_id, .. } - | NodeToHostMessage::Terminated { request_id, .. } - | NodeToHostMessage::Result { request_id, .. } => Some(request_id), - NodeToHostMessage::Notify { .. } => None, - } -} - -pub(super) fn unexpected_tool_call_error() -> String { - format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response") -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::CodeModeNotify; - use super::NodeToHostMessage; - use super::message_request_id; - - #[test] - fn message_request_id_absent_for_notify() { - let message = NodeToHostMessage::Notify { - notify: CodeModeNotify { - cell_id: "1".to_string(), - call_id: "call-1".to_string(), - text: "hello".to_string(), - }, - }; - - assert_eq!(None, message_request_id(&message)); - } - - #[test] - fn message_request_id_present_for_result() { - let message = NodeToHostMessage::Result { - request_id: "req-1".to_string(), - content_items: Vec::new(), - stored_values: HashMap::new(), - error_text: None, - max_output_tokens_per_exec_call: None, - }; - - assert_eq!(Some("req-1"), message_request_id(&message)); - } -} diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs new file mode 100644 index 0000000000..b90448acf9 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -0,0 +1,44 @@ +use codex_code_mode::ImageDetail as CodeModeImageDetail; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::ImageDetail; + +trait IntoProtocol { + fn into_protocol(self) -> T; +} + +pub(super) fn into_function_call_output_content_items( + items: Vec, +) -> Vec { + items.into_iter().map(IntoProtocol::into_protocol).collect() +} + +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, + } + } +} + +impl IntoProtocol + for codex_code_mode::FunctionCallOutputContentItem +{ + fn into_protocol(self) -> FunctionCallOutputContentItem { + let value = self; + match value { + codex_code_mode::FunctionCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(IntoProtocol::into_protocol), + } + } + } + } +} diff --git a/codex-rs/core/src/tools/code_mode/runner.cjs b/codex-rs/core/src/tools/code_mode/runner.cjs deleted file mode 100644 index 8b4b322eb3..0000000000 --- a/codex-rs/core/src/tools/code_mode/runner.cjs +++ /dev/null @@ -1,938 +0,0 @@ -'use strict'; - -const readline = require('node:readline'); -const { Worker } = require('node:worker_threads'); - -const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; - -function normalizeMaxOutputTokensPerExecCall(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); - } - return value; -} - -function normalizeYieldTime(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('yield_time must be a non-negative safe integer'); - } - return value; -} - -function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function clearTimer(timer) { - if (timer !== null) { - clearTimeout(timer); - } - return null; -} - -function takeContentItems(session) { - const clonedContentItems = cloneJsonValue(session.content_items); - session.content_items.splice(0, session.content_items.length); - return Array.isArray(clonedContentItems) ? clonedContentItems : []; -} - -function codeModeWorkerMain() { - 'use strict'; - - const { parentPort, workerData } = require('node:worker_threads'); - const vm = require('node:vm'); - const { SourceTextModule, SyntheticModule } = vm; - - function formatErrorText(error) { - return String(error && error.stack ? error.stack : error); - } - - function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); - } - - class CodeModeExitSignal extends Error { - constructor() { - super('code mode exit'); - this.name = 'CodeModeExitSignal'; - } - } - - function isCodeModeExitSignal(error) { - return error instanceof CodeModeExitSignal; - } - - function createToolCaller() { - let nextId = 0; - const pending = new Map(); - - parentPort.on('message', (message) => { - if (message.type === 'tool_response') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.resolve(message.result ?? ''); - return; - } - - if (message.type === 'tool_response_error') { - const entry = pending.get(message.id); - if (!entry) { - return; - } - pending.delete(message.id); - entry.reject(new Error(message.error_text ?? 'tool call failed')); - return; - } - }); - - return (name, input) => { - const id = 'msg-' + ++nextId; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - parentPort.postMessage({ - type: 'tool_call', - id, - name: String(name), - input, - }); - }); - }; - } - - function createContentItems() { - const contentItems = []; - const push = contentItems.push.bind(contentItems); - contentItems.push = (...items) => { - for (const item of items) { - parentPort.postMessage({ - type: 'content_item', - item: cloneJsonValue(item), - }); - } - return push(...items); - }; - parentPort.on('message', (message) => { - if (message.type === 'clear_content') { - contentItems.splice(0, contentItems.length); - } - }); - return contentItems; - } - - function createGlobalToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createModuleToolsNamespace(callTool, enabledTools) { - const tools = Object.create(null); - - for (const { tool_name, global_name } of enabledTools) { - Object.defineProperty(tools, global_name, { - value: async (args) => callTool(tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createAllToolsMetadata(enabledTools) { - return Object.freeze( - enabledTools.map(({ global_name, description }) => - Object.freeze({ - name: global_name, - description, - }) - ) - ); - } - - function createToolsModule(context, callTool, enabledTools) { - const tools = createModuleToolsNamespace(callTool, enabledTools); - const allTools = createAllToolsMetadata(enabledTools); - const exportNames = ['ALL_TOOLS']; - - for (const { global_name } of enabledTools) { - if (global_name !== 'ALL_TOOLS') { - exportNames.push(global_name); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initToolsModule() { - this.setExport('ALL_TOOLS', allTools); - for (const exportName of uniqueExportNames) { - if (exportName !== 'ALL_TOOLS') { - this.setExport(exportName, tools[exportName]); - } - } - }, - { context } - ); - } - - function ensureContentItems(context) { - if (!Array.isArray(context.__codexContentItems)) { - context.__codexContentItems = []; - } - return context.__codexContentItems; - } - - function serializeOutputText(value) { - if (typeof value === 'string') { - return value; - } - if ( - typeof value === 'undefined' || - value === null || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'bigint' - ) { - return String(value); - } - - const serialized = JSON.stringify(value); - if (typeof serialized === 'string') { - return serialized; - } - - return String(value); - } - - function normalizeOutputImage(value) { - let imageUrl; - let detail; - if (typeof value === 'string') { - imageUrl = value; - } else if ( - value && - typeof value === 'object' && - !Array.isArray(value) - ) { - if (typeof value.image_url === 'string') { - imageUrl = value.image_url; - } - if (typeof value.detail === 'string') { - detail = value.detail; - } else if ( - Object.prototype.hasOwnProperty.call(value, 'detail') && - value.detail !== null && - typeof value.detail !== 'undefined' - ) { - throw new TypeError('image detail must be a string when provided'); - } - } - - if (typeof imageUrl !== 'string' || !imageUrl) { - throw new TypeError( - 'image expects a non-empty image URL string or an object with image_url and optional detail' - ); - } - if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) { - throw new TypeError('image expects an http(s) or data URL'); - } - - if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) { - throw new TypeError('image detail must be one of: auto, low, high, original'); - } - - const normalized = { image_url: imageUrl }; - if (typeof detail === 'string') { - normalized.detail = detail.toLowerCase(); - } - return normalized; - } - - function createCodeModeHelpers(context, state, toolCallId) { - const load = (key) => { - if (typeof key !== 'string') { - throw new TypeError('load key must be a string'); - } - if (!Object.prototype.hasOwnProperty.call(state.storedValues, key)) { - return undefined; - } - return cloneJsonValue(state.storedValues[key]); - }; - const store = (key, value) => { - if (typeof key !== 'string') { - throw new TypeError('store key must be a string'); - } - state.storedValues[key] = cloneJsonValue(value); - }; - const text = (value) => { - const item = { - type: 'input_text', - text: serializeOutputText(value), - }; - ensureContentItems(context).push(item); - return item; - }; - const image = (value) => { - const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value)); - ensureContentItems(context).push(item); - return item; - }; - const yieldControl = () => { - parentPort.postMessage({ type: 'yield' }); - }; - const notify = (value) => { - const text = serializeOutputText(value); - if (text.trim().length === 0) { - throw new TypeError('notify expects non-empty text'); - } - if (typeof toolCallId !== 'string' || toolCallId.length === 0) { - throw new TypeError('notify requires a valid tool call id'); - } - parentPort.postMessage({ - type: 'notify', - call_id: toolCallId, - text, - }); - return text; - }; - const exit = () => { - throw new CodeModeExitSignal(); - }; - - return Object.freeze({ - exit, - image, - load, - notify, - output_image: image, - output_text: text, - store, - text, - yield_control: yieldControl, - }); - } - - function createCodeModeModule(context, helpers) { - return new SyntheticModule( - [ - 'exit', - 'image', - 'load', - 'notify', - 'output_text', - 'output_image', - 'store', - 'text', - 'yield_control', - ], - function initCodeModeModule() { - this.setExport('exit', helpers.exit); - this.setExport('image', helpers.image); - this.setExport('load', helpers.load); - this.setExport('notify', helpers.notify); - this.setExport('output_text', helpers.output_text); - this.setExport('output_image', helpers.output_image); - this.setExport('store', helpers.store); - this.setExport('text', helpers.text); - this.setExport('yield_control', helpers.yield_control); - }, - { context } - ); - } - - function createBridgeRuntime(callTool, enabledTools, helpers) { - return Object.freeze({ - ALL_TOOLS: createAllToolsMetadata(enabledTools), - exit: helpers.exit, - image: helpers.image, - load: helpers.load, - notify: helpers.notify, - store: helpers.store, - text: helpers.text, - tools: createGlobalToolsNamespace(callTool, enabledTools), - yield_control: helpers.yield_control, - }); - } - - function namespacesMatch(left, right) { - if (left.length !== right.length) { - return false; - } - return left.every((segment, index) => segment === right[index]); - } - - function createNamespacedToolsNamespace(callTool, enabledTools, namespace) { - const tools = Object.create(null); - - for (const tool of enabledTools) { - const toolNamespace = Array.isArray(tool.namespace) ? tool.namespace : []; - if (!namespacesMatch(toolNamespace, namespace)) { - continue; - } - - Object.defineProperty(tools, tool.name, { - value: async (args) => callTool(tool.tool_name, args), - configurable: false, - enumerable: true, - writable: false, - }); - } - - return Object.freeze(tools); - } - - function createNamespacedToolsModule(context, callTool, enabledTools, namespace) { - const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace); - const exportNames = []; - - for (const exportName of Object.keys(tools)) { - if (exportName !== 'ALL_TOOLS') { - exportNames.push(exportName); - } - } - - const uniqueExportNames = [...new Set(exportNames)]; - - return new SyntheticModule( - uniqueExportNames, - function initNamespacedToolsModule() { - for (const exportName of uniqueExportNames) { - this.setExport(exportName, tools[exportName]); - } - }, - { context } - ); - } - - function createModuleResolver(context, callTool, enabledTools, helpers) { - let toolsModule; - let codeModeModule; - const namespacedModules = new Map(); - - return function resolveModule(specifier) { - if (specifier === 'tools.js') { - toolsModule ??= createToolsModule(context, callTool, enabledTools); - return toolsModule; - } - if (specifier === '@openai/code_mode' || specifier === 'openai/code_mode') { - codeModeModule ??= createCodeModeModule(context, helpers); - return codeModeModule; - } - const namespacedMatch = /^tools\/(.+)\.js$/.exec(specifier); - if (!namespacedMatch) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const namespace = namespacedMatch[1] - .split('/') - .filter((segment) => segment.length > 0); - if (namespace.length === 0) { - throw new Error('Unsupported import in exec: ' + specifier); - } - - const cacheKey = namespace.join('/'); - if (!namespacedModules.has(cacheKey)) { - namespacedModules.set( - cacheKey, - createNamespacedToolsModule(context, callTool, enabledTools, namespace) - ); - } - return namespacedModules.get(cacheKey); - }; - } - - async function resolveDynamicModule(specifier, resolveModule) { - const module = resolveModule(specifier); - - if (module.status === 'unlinked') { - await module.link(resolveModule); - } - - if (module.status === 'linked' || module.status === 'evaluating') { - await module.evaluate(); - } - - if (module.status === 'errored') { - throw module.error; - } - - return module; - } - - async function runModule(context, start, callTool, helpers) { - const resolveModule = createModuleResolver( - context, - callTool, - start.enabled_tools ?? [], - helpers - ); - const mainModule = new SourceTextModule(start.source, { - context, - identifier: 'exec_main.mjs', - importModuleDynamically: async (specifier) => - resolveDynamicModule(specifier, resolveModule), - }); - - await mainModule.link(resolveModule); - await mainModule.evaluate(); - } - - async function main() { - const start = workerData ?? {}; - const toolCallId = start.tool_call_id; - const state = { - storedValues: cloneJsonValue(start.stored_values ?? {}), - }; - const callTool = createToolCaller(); - const enabledTools = start.enabled_tools ?? []; - const contentItems = createContentItems(); - const context = vm.createContext({ - __codexContentItems: contentItems, - }); - const helpers = createCodeModeHelpers(context, state, toolCallId); - Object.defineProperty(context, '__codexRuntime', { - value: createBridgeRuntime(callTool, enabledTools, helpers), - configurable: true, - enumerable: false, - writable: false, - }); - - parentPort.postMessage({ type: 'started' }); - try { - await runModule(context, start, callTool, helpers); - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - } catch (error) { - if (isCodeModeExitSignal(error)) { - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - }); - return; - } - parentPort.postMessage({ - type: 'result', - stored_values: state.storedValues, - error_text: formatErrorText(error), - }); - } - } - - void main().catch((error) => { - parentPort.postMessage({ - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); -} - -function createProtocol() { - const rl = readline.createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - let nextId = 0; - const pending = new Map(); - const sessions = new Map(); - let closedResolve; - const closed = new Promise((resolve) => { - closedResolve = resolve; - }); - - rl.on('line', (line) => { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch (error) { - process.stderr.write(formatErrorText(error) + '\n'); - return; - } - - if (message.type === 'start') { - startSession(protocol, sessions, message); - return; - } - - if (message.type === 'poll') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - if (session.pending_result) { - void completeSession(protocol, sessions, session, session.pending_result); - } else { - schedulePollYield(protocol, session, normalizeYieldTime(message.yield_time_ms ?? 0)); - } - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'terminate') { - const session = sessions.get(message.cell_id); - if (session) { - session.request_id = String(message.request_id); - void terminateSession(protocol, sessions, session); - } else { - void protocol.send({ - type: 'result', - request_id: message.request_id, - content_items: [], - stored_values: {}, - error_text: `exec cell ${message.cell_id} not found`, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, - }); - } - return; - } - - if (message.type === 'response') { - const entry = pending.get(message.request_id + ':' + message.id); - if (!entry) { - return; - } - pending.delete(message.request_id + ':' + message.id); - if (typeof message.error_text === 'string') { - entry.reject(new Error(message.error_text)); - return; - } - entry.resolve(message.code_mode_result ?? ''); - return; - } - - process.stderr.write('Unknown protocol message type: ' + message.type + '\n'); - }); - - rl.on('close', () => { - const error = new Error('stdin closed'); - for (const entry of pending.values()) { - entry.reject(error); - } - pending.clear(); - for (const session of sessions.values()) { - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - void session.worker.terminate().catch(() => {}); - } - sessions.clear(); - closedResolve(); - }); - - function send(message) { - return new Promise((resolve, reject) => { - process.stdout.write(JSON.stringify(message) + '\n', (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - } - - function request(type, payload) { - const requestId = 'req-' + ++nextId; - const id = 'msg-' + ++nextId; - const pendingKey = requestId + ':' + id; - return new Promise((resolve, reject) => { - pending.set(pendingKey, { resolve, reject }); - void send({ type, request_id: requestId, id, ...payload }).catch((error) => { - pending.delete(pendingKey); - reject(error); - }); - }); - } - - const protocol = { closed, request, send }; - return protocol; -} - -function sessionWorkerSource() { - return '(' + codeModeWorkerMain.toString() + ')();'; -} - -function startSession(protocol, sessions, start) { - if (typeof start.tool_call_id !== 'string' || start.tool_call_id.length === 0) { - throw new TypeError('start requires a valid tool_call_id'); - } - const maxOutputTokensPerExecCall = - start.max_output_tokens == null - ? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL - : normalizeMaxOutputTokensPerExecCall(start.max_output_tokens); - const session = { - completed: false, - content_items: [], - default_yield_time_ms: normalizeYieldTime(start.default_yield_time_ms), - id: start.cell_id, - initial_yield_time_ms: - start.yield_time_ms == null - ? normalizeYieldTime(start.default_yield_time_ms) - : normalizeYieldTime(start.yield_time_ms), - initial_yield_timer: null, - initial_yield_triggered: false, - max_output_tokens_per_exec_call: maxOutputTokensPerExecCall, - pending_result: null, - poll_yield_timer: null, - request_id: String(start.request_id), - worker: new Worker(sessionWorkerSource(), { - eval: true, - workerData: start, - }), - }; - sessions.set(session.id, session); - - session.worker.on('message', (message) => { - void handleWorkerMessage(protocol, sessions, session, message).catch((error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - }); - session.worker.on('error', (error) => { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: formatErrorText(error), - }); - }); - session.worker.on('exit', (code) => { - if (code !== 0 && !session.completed) { - void completeSession(protocol, sessions, session, { - type: 'result', - stored_values: {}, - error_text: 'exec worker exited with code ' + code, - }); - } - }); -} - -async function handleWorkerMessage(protocol, sessions, session, message) { - if (session.completed) { - return; - } - - if (message.type === 'content_item') { - session.content_items.push(cloneJsonValue(message.item)); - return; - } - - if (message.type === 'started') { - scheduleInitialYield(protocol, session, session.initial_yield_time_ms); - return; - } - - if (message.type === 'yield') { - void sendYielded(protocol, session); - return; - } - - if (message.type === 'notify') { - if (typeof message.text !== 'string' || message.text.trim().length === 0) { - throw new TypeError('notify requires non-empty text'); - } - if (typeof message.call_id !== 'string' || message.call_id.length === 0) { - throw new TypeError('notify requires a valid call id'); - } - await protocol.send({ - type: 'notify', - cell_id: session.id, - call_id: message.call_id, - text: message.text, - }); - return; - } - - if (message.type === 'tool_call') { - void forwardToolCall(protocol, session, message); - return; - } - - if (message.type === 'result') { - const result = { - type: 'result', - stored_values: cloneJsonValue(message.stored_values ?? {}), - error_text: - typeof message.error_text === 'string' ? message.error_text : undefined, - }; - if (session.request_id === null) { - session.pending_result = result; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - await completeSession(protocol, sessions, session, result); - return; - } - - process.stderr.write('Unknown worker message type: ' + message.type + '\n'); -} - -async function forwardToolCall(protocol, session, message) { - try { - const result = await protocol.request('tool_call', { - name: String(message.name), - input: message.input, - }); - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response', - id: message.id, - result, - }); - } catch {} - } catch (error) { - if (session.completed) { - return; - } - try { - session.worker.postMessage({ - type: 'tool_response_error', - id: message.id, - error_text: formatErrorText(error), - }); - } catch {} - } -} - -async function sendYielded(protocol, session) { - if (session.completed || session.request_id === null) { - return; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_triggered = true; - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - const contentItems = takeContentItems(session); - const requestId = session.request_id; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - type: 'yielded', - request_id: requestId, - content_items: contentItems, - }); - session.request_id = null; -} - -function scheduleInitialYield(protocol, session, yieldTime) { - if (session.completed || session.initial_yield_triggered) { - return yieldTime; - } - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.initial_yield_timer = setTimeout(() => { - session.initial_yield_timer = null; - session.initial_yield_triggered = true; - void sendYielded(protocol, session); - }, yieldTime); - return yieldTime; -} - -function schedulePollYield(protocol, session, yieldTime) { - if (session.completed) { - return; - } - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - session.poll_yield_timer = setTimeout(() => { - session.poll_yield_timer = null; - void sendYielded(protocol, session); - }, yieldTime); -} - -async function completeSession(protocol, sessions, session, message) { - if (session.completed) { - return; - } - if (session.request_id === null) { - session.pending_result = message; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - return; - } - const requestId = session.request_id; - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - session.pending_result = null; - try { - session.worker.postMessage({ type: 'clear_content' }); - } catch {} - await protocol.send({ - ...message, - request_id: requestId, - content_items: contentItems, - max_output_tokens_per_exec_call: session.max_output_tokens_per_exec_call, - }); -} - -async function terminateSession(protocol, sessions, session) { - if (session.completed) { - return; - } - session.completed = true; - session.initial_yield_timer = clearTimer(session.initial_yield_timer); - session.poll_yield_timer = clearTimer(session.poll_yield_timer); - sessions.delete(session.id); - const contentItems = takeContentItems(session); - try { - await session.worker.terminate(); - } catch {} - await protocol.send({ - type: 'terminated', - request_id: session.request_id, - content_items: contentItems, - }); -} - -async function main() { - const protocol = createProtocol(); - await protocol.closed; -} - -void main().catch(async (error) => { - try { - process.stderr.write(formatErrorText(error) + '\n'); - } finally { - process.exitCode = 1; - } -}); diff --git a/codex-rs/core/src/tools/code_mode/service.rs b/codex-rs/core/src/tools/code_mode/service.rs deleted file mode 100644 index a9fadedb82..0000000000 --- a/codex-rs/core/src/tools/code_mode/service.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - -use serde_json::Value as JsonValue; -use tokio::sync::Mutex; -use tracing::warn; - -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::tools::ToolRouter; -use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::js_repl::resolve_compatible_node; -use crate::tools::parallel::ToolCallRuntime; -use codex_features::Feature; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::process::CodeModeProcess; -use super::process::spawn_code_mode_process; -use super::worker::CodeModeWorker; - -pub(crate) struct CodeModeService { - js_repl_node_path: Option, - stored_values: Mutex>, - process: Arc>>, - next_cell_id: Mutex, -} - -impl CodeModeService { - pub(crate) fn new(js_repl_node_path: Option) -> Self { - Self { - js_repl_node_path, - stored_values: Mutex::new(HashMap::new()), - process: Arc::new(Mutex::new(None)), - next_cell_id: Mutex::new(1), - } - } - - pub(crate) async fn stored_values(&self) -> HashMap { - self.stored_values.lock().await.clone() - } - - pub(crate) async fn replace_stored_values(&self, values: HashMap) { - *self.stored_values.lock().await = values; - } - - pub(super) async fn ensure_started( - &self, - ) -> Result>, std::io::Error> { - let mut process_slot = self.process.lock().await; - let needs_spawn = match process_slot.as_mut() { - Some(process) => !matches!(process.has_exited(), Ok(false)), - None => true, - }; - if needs_spawn { - let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref()) - .await - .map_err(std::io::Error::other)?; - *process_slot = Some(spawn_code_mode_process(&node_path).await?); - } - drop(process_slot); - Ok(self.process.clone().lock_owned().await) - } - - pub(crate) async fn start_turn_worker( - &self, - session: &Arc, - turn: &Arc, - router: Arc, - tracker: SharedTurnDiffTracker, - ) -> Option { - if !turn.features.enabled(Feature::CodeMode) { - return None; - } - let exec = ExecContext { - session: Arc::clone(session), - turn: Arc::clone(turn), - }; - let tool_runtime = - ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); - let mut process_slot = match self.ensure_started().await { - Ok(process_slot) => process_slot, - Err(err) => { - warn!("failed to start {PUBLIC_TOOL_NAME} worker for turn: {err}"); - return None; - } - }; - let Some(process) = process_slot.as_mut() else { - warn!( - "failed to start {PUBLIC_TOOL_NAME} worker for turn: {PUBLIC_TOOL_NAME} runner failed to start" - ); - return None; - }; - Some(process.worker(exec, tool_runtime)) - } - - pub(crate) async fn allocate_cell_id(&self) -> String { - let mut next_cell_id = self.next_cell_id.lock().await; - let cell_id = *next_cell_id; - *next_cell_id = next_cell_id.saturating_add(1); - cell_id.to_string() - } - - pub(crate) async fn allocate_request_id(&self) -> String { - uuid::Uuid::new_v4().to_string() - } -} diff --git a/codex-rs/core/src/tools/code_mode/wait_description.md b/codex-rs/core/src/tools/code_mode/wait_description.md deleted file mode 100644 index 41b928f514..0000000000 --- a/codex-rs/core/src/tools/code_mode/wait_description.md +++ /dev/null @@ -1,8 +0,0 @@ -- Use `wait` only after `exec` returns `Script running with cell ID ...`. -- `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. -- `max_tokens` limits how much new output this wait call returns. -- `terminate: true` stops the running cell instead of waiting for more output. -- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. -- If the cell is still running, `wait` may yield again with the same `cell_id`. -- If the cell has already finished, `wait` returns the completed result and closes the cell. 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 caaf8c8c44..f319985a88 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -8,13 +8,10 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use super::CodeModeSessionProgress; use super::DEFAULT_WAIT_YIELD_TIME_MS; use super::ExecContext; -use super::PUBLIC_TOOL_NAME; use super::WAIT_TOOL_NAME; -use super::handle_node_message; -use super::protocol::HostToNodeMessage; +use super::handle_runtime_response; pub struct CodeModeWaitHandler; @@ -63,66 +60,21 @@ impl ToolHandler for CodeModeWaitHandler { ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => { let args: ExecWaitArgs = parse_arguments(&arguments)?; let exec = ExecContext { session, turn }; - let request_id = exec - .session - .services - .code_mode_service - .allocate_request_id() - .await; let started_at = std::time::Instant::now(); - let message = if args.terminate { - HostToNodeMessage::Terminate { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - } - } else { - HostToNodeMessage::Poll { - request_id: request_id.clone(), - cell_id: args.cell_id.clone(), - yield_time_ms: args.yield_time_ms, - } - }; - let process_slot = exec + let response = exec .session .services .code_mode_service - .ensure_started() + .wait(codex_code_mode::WaitRequest { + cell_id: args.cell_id, + yield_time_ms: args.yield_time_ms, + terminate: args.terminate, + }) .await - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; - let result = { - let mut process_slot = process_slot; - let Some(process) = process_slot.as_mut() else { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - }; - if !matches!(process.has_exited(), Ok(false)) { - return Err(FunctionCallError::RespondToModel(format!( - "{PUBLIC_TOOL_NAME} runner failed to start" - ))); - } - let message = process - .send(&request_id, &message) - .await - .map_err(|err| err.to_string()); - let message = match message { - Ok(message) => message, - Err(error) => return Err(FunctionCallError::RespondToModel(error)), - }; - handle_node_message( - &exec, - args.cell_id, - message, - Some(args.max_tokens), - started_at, - ) + .map_err(FunctionCallError::RespondToModel)?; + handle_runtime_response(&exec, response, args.max_tokens, started_at) .await - }; - match result { - Ok(CodeModeSessionProgress::Finished(output)) - | Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output), - Err(error) => Err(FunctionCallError::RespondToModel(error)), - } + .map_err(FunctionCallError::RespondToModel) } _ => Err(FunctionCallError::RespondToModel(format!( "{WAIT_TOOL_NAME} expects JSON arguments" diff --git a/codex-rs/core/src/tools/code_mode/worker.rs b/codex-rs/core/src/tools/code_mode/worker.rs deleted file mode 100644 index 5853f3abe3..0000000000 --- a/codex-rs/core/src/tools/code_mode/worker.rs +++ /dev/null @@ -1,116 +0,0 @@ -use tokio::sync::oneshot; -use tokio_util::sync::CancellationToken; -use tracing::error; -use tracing::warn; - -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; - -use super::ExecContext; -use super::PUBLIC_TOOL_NAME; -use super::call_nested_tool; -use super::process::CodeModeProcess; -use super::process::write_message; -use super::protocol::HostToNodeMessage; -use super::protocol::NodeToHostMessage; -use crate::tools::parallel::ToolCallRuntime; - -pub(crate) struct CodeModeWorker { - shutdown_tx: Option>, -} - -impl Drop for CodeModeWorker { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -impl CodeModeProcess { - pub(super) fn worker( - &self, - exec: ExecContext, - tool_runtime: ToolCallRuntime, - ) -> CodeModeWorker { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let stdin = self.stdin.clone(); - let message_rx = self.message_rx.clone(); - tokio::spawn(async move { - loop { - let next_message = tokio::select! { - _ = &mut shutdown_rx => break, - message = async { - let mut message_rx = message_rx.lock().await; - message_rx.recv().await - } => message, - }; - let Some(next_message) = next_message else { - break; - }; - match next_message { - NodeToHostMessage::ToolCall { tool_call } => { - let exec = exec.clone(); - let tool_runtime = tool_runtime.clone(); - let stdin = stdin.clone(); - tokio::spawn(async move { - let result = call_nested_tool( - exec, - tool_runtime, - tool_call.name, - tool_call.input, - CancellationToken::new(), - ) - .await; - let (code_mode_result, error_text) = match result { - Ok(code_mode_result) => (code_mode_result, None), - Err(error) => (serde_json::Value::Null, Some(error.to_string())), - }; - let response = HostToNodeMessage::Response { - request_id: tool_call.request_id, - id: tool_call.id, - code_mode_result, - error_text, - }; - if let Err(err) = write_message(&stdin, &response).await { - warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}"); - } - }); - } - NodeToHostMessage::Notify { notify } => { - if notify.text.trim().is_empty() { - continue; - } - if exec - .session - .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { - call_id: notify.call_id.clone(), - name: Some(PUBLIC_TOOL_NAME.to_string()), - output: FunctionCallOutputPayload::from_text(notify.text), - }]) - .await - .is_err() - { - warn!( - "failed to inject {PUBLIC_TOOL_NAME} notify message for cell {}: no active turn", - notify.cell_id - ); - } - } - unexpected_message @ (NodeToHostMessage::Yielded { .. } - | NodeToHostMessage::Terminated { .. } - | NodeToHostMessage::Result { .. }) => { - error!( - "received unexpected {PUBLIC_TOOL_NAME} message in worker loop: {unexpected_message:?}" - ); - break; - } - } - } - }); - - CodeModeWorker { - shutdown_tx: Some(shutdown_tx), - } - } -} diff --git a/codex-rs/core/src/tools/code_mode_description.rs b/codex-rs/core/src/tools/code_mode_description.rs index b7722aeb72..fb4fc1f519 100644 --- a/codex-rs/core/src/tools/code_mode_description.rs +++ b/codex-rs/core/src/tools/code_mode_description.rs @@ -1,30 +1,11 @@ use crate::client_common::tools::ToolSpec; -use crate::mcp::split_qualified_tool_name; -use crate::tools::code_mode::PUBLIC_TOOL_NAME; -use serde_json::Value as JsonValue; -pub(crate) struct CodeModeToolReference { - pub(crate) module_path: String, - pub(crate) namespace: Vec, - pub(crate) tool_key: String, -} - -pub(crate) fn code_mode_tool_reference(tool_name: &str) -> CodeModeToolReference { - if let Some((server_name, tool_key)) = split_qualified_tool_name(tool_name) { - let namespace = vec!["mcp".to_string(), server_name]; - return CodeModeToolReference { - module_path: format!("tools/{}.js", namespace.join("/")), - namespace, - tool_key, - }; - } - - CodeModeToolReference { - module_path: "tools.js".to_string(), - namespace: Vec::new(), - tool_key: tool_name.to_string(), - } -} +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::append_code_mode_sample; +#[allow(unused_imports)] +#[cfg(test)] +pub(crate) use codex_code_mode::render_json_schema_to_typescript; pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec { if !code_mode_enabled { @@ -33,27 +14,27 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: match spec { ToolSpec::Function(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( - &tool.description, - &tool.name, - "args", - serde_json::to_value(&tool.parameters) - .ok() - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - tool.output_schema - .as_ref() - .map(render_json_schema_to_typescript) - .unwrap_or_else(|| "unknown".to_string()), - ); - } + let input_type = serde_json::to_value(&tool.parameters) + .ok() + .map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema)) + .unwrap_or_else(|| "unknown".to_string()); + let output_type = tool + .output_schema + .as_ref() + .map(codex_code_mode::render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()); + tool.description = codex_code_mode::append_code_mode_sample( + &tool.description, + &tool.name, + "args", + input_type, + output_type, + ); ToolSpec::Function(tool) } ToolSpec::Freeform(mut tool) => { - if tool.name != PUBLIC_TOOL_NAME { - tool.description = append_code_mode_sample( + if tool.name != codex_code_mode::PUBLIC_TOOL_NAME { + tool.description = codex_code_mode::append_code_mode_sample( &tool.description, &tool.name, "input", @@ -66,234 +47,3 @@ pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: other => other, } } - -fn append_code_mode_sample( - description: &str, - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let declaration = format!( - "declare const tools: {{ {} }};", - render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) - ); - format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") -} - -fn render_code_mode_tool_declaration( - tool_name: &str, - input_name: &str, - input_type: String, - output_type: String, -) -> String { - let tool_name = normalize_code_mode_identifier(tool_name); - format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") -} - -pub(crate) fn normalize_code_mode_identifier(tool_key: &str) -> String { - let mut identifier = String::new(); - - for (index, ch) in tool_key.chars().enumerate() { - let is_valid = if index == 0 { - ch == '_' || ch == '$' || ch.is_ascii_alphabetic() - } else { - ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() - }; - - if is_valid { - identifier.push(ch); - } else { - identifier.push('_'); - } - } - - if identifier.is_empty() { - "_".to_string() - } else { - identifier - } -} - -fn render_json_schema_to_typescript(schema: &JsonValue) -> String { - render_json_schema_to_typescript_inner(schema) -} - -fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { - match schema { - JsonValue::Bool(true) => "unknown".to_string(), - JsonValue::Bool(false) => "never".to_string(), - JsonValue::Object(map) => { - if let Some(value) = map.get("const") { - return render_json_schema_literal(value); - } - - if let Some(values) = map.get("enum").and_then(serde_json::Value::as_array) { - let rendered = values - .iter() - .map(render_json_schema_literal) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - for key in ["anyOf", "oneOf"] { - if let Some(variants) = map.get(key).and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - } - - if let Some(variants) = map.get("allOf").and_then(serde_json::Value::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" & "); - } - } - - if let Some(schema_type) = map.get("type") { - if let Some(types) = schema_type.as_array() { - let rendered = types - .iter() - .filter_map(serde_json::Value::as_str) - .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - if let Some(schema_type) = schema_type.as_str() { - return render_json_schema_type_keyword(map, schema_type); - } - } - - if map.contains_key("properties") - || map.contains_key("additionalProperties") - || map.contains_key("required") - { - return render_json_schema_object(map); - } - - if map.contains_key("items") || map.contains_key("prefixItems") { - return render_json_schema_array(map); - } - - "unknown".to_string() - } - _ => "unknown".to_string(), - } -} - -fn render_json_schema_type_keyword( - map: &serde_json::Map, - schema_type: &str, -) -> String { - match schema_type { - "string" => "string".to_string(), - "number" | "integer" => "number".to_string(), - "boolean" => "boolean".to_string(), - "null" => "null".to_string(), - "array" => render_json_schema_array(map), - "object" => render_json_schema_object(map), - _ => "unknown".to_string(), - } -} - -fn render_json_schema_array(map: &serde_json::Map) -> String { - if let Some(items) = map.get("items") { - let item_type = render_json_schema_to_typescript_inner(items); - return format!("Array<{item_type}>"); - } - - if let Some(items) = map.get("prefixItems").and_then(serde_json::Value::as_array) { - let item_types = items - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !item_types.is_empty() { - return format!("[{}]", item_types.join(", ")); - } - } - - "unknown[]".to_string() -} - -fn render_json_schema_object(map: &serde_json::Map) -> String { - let required = map - .get("required") - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - }) - .unwrap_or_default(); - let properties = map - .get("properties") - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - - let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); - let mut lines = sorted_properties - .into_iter() - .map(|(name, value)| { - let optional = if required.iter().any(|required_name| required_name == name) { - "" - } else { - "?" - }; - let property_name = render_json_schema_property_name(name); - let property_type = render_json_schema_to_typescript_inner(value); - format!("{property_name}{optional}: {property_type};") - }) - .collect::>(); - - if let Some(additional_properties) = map.get("additionalProperties") { - let additional_type = match additional_properties { - JsonValue::Bool(true) => Some("unknown".to_string()), - JsonValue::Bool(false) => None, - value => Some(render_json_schema_to_typescript_inner(value)), - }; - - if let Some(additional_type) = additional_type { - lines.push(format!("[key: string]: {additional_type};")); - } - } else if properties.is_empty() { - lines.push("[key: string]: unknown;".to_string()); - } - - if lines.is_empty() { - return "{}".to_string(); - } - - format!("{{ {} }}", lines.join(" ")) -} - -fn render_json_schema_property_name(name: &str) -> String { - if normalize_code_mode_identifier(name) == name { - name.to_string() - } else { - serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) - } -} - -fn render_json_schema_literal(value: &JsonValue) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) -} - -#[cfg(test)] -#[path = "code_mode_description_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 06ddc76df7..9b14f7ee50 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -8,6 +8,8 @@ mod list_dir; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; +pub(crate) mod multi_agents_common; +pub(crate) mod multi_agents_v2; mod plan; mod read_file; mod request_permissions; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 897af0d5f0..0bf7b7bcd3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -11,45 +11,33 @@ use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; -use crate::config::Config; -use crate::error::CodexErr; use crate::function_tool::FunctionCallError; -use crate::models_manager::manager::RefreshStrategy; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; -use codex_features::Feature; -use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; -use std::sync::Arc; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; @@ -57,368 +45,12 @@ pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; -/// Minimum wait timeout to prevent tight polling loops from burning CPU. -pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; - -fn function_arguments(payload: ToolPayload) -> Result { - match payload { - ToolPayload::Function { arguments } => Ok(arguments), - _ => Err(FunctionCallError::RespondToModel( - "collab handler received unsupported payload".to_string(), - )), - } -} - -fn tool_output_json_text(value: &T, tool_name: &str) -> String -where - T: Serialize, -{ - serde_json::to_string(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() - }) -} - -fn tool_output_response_item( - call_id: &str, - payload: &ToolPayload, - value: &T, - success: Option, - tool_name: &str, -) -> ResponseInputItem -where - T: Serialize, -{ - FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) - .to_response_item(call_id, payload) -} - -fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue -where - T: Serialize, -{ - serde_json::to_value(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) - }) -} - pub mod close_agent; mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; -fn build_wait_agent_statuses( - statuses: &HashMap, - receiver_agents: &[CollabAgentRef], -) -> Vec { - if statuses.is_empty() { - return Vec::new(); - } - - let mut entries = Vec::with_capacity(statuses.len()); - let mut seen = HashMap::with_capacity(receiver_agents.len()); - for receiver_agent in receiver_agents { - seen.insert(receiver_agent.thread_id, ()); - if let Some(status) = statuses.get(&receiver_agent.thread_id) { - entries.push(CollabAgentStatusEntry { - thread_id: receiver_agent.thread_id, - agent_nickname: receiver_agent.agent_nickname.clone(), - agent_role: receiver_agent.agent_role.clone(), - status: status.clone(), - }); - } - } - - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains_key(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); - entries.extend(extras); - entries -} - -fn collab_spawn_error(err: CodexErr) -> FunctionCallError { - match err { - CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), - err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), - } -} - -fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { - match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - CodexErr::InternalAgentDied => { - FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) - } - CodexErr::UnsupportedOperation(_) => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), - } -} - -fn thread_spawn_source( - parent_thread_id: ThreadId, - parent_session_source: &SessionSource, - depth: i32, - agent_role: Option<&str>, - task_name: Option, -) -> Result { - let agent_path = task_name - .as_deref() - .map(|task_name| { - parent_session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root) - .join(task_name) - .map_err(FunctionCallError::RespondToModel) - }) - .transpose()?; - Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_nickname: None, - agent_role: agent_role.map(str::to_string), - })) -} - -fn parse_collab_input( - message: Option, - items: Option>, -) -> Result, FunctionCallError> { - match (message, items) { - (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( - "Provide either message or items, but not both".to_string(), - )), - (None, None) => Err(FunctionCallError::RespondToModel( - "Provide one of: message or items".to_string(), - )), - (Some(message), None) => { - if message.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "Empty message can't be sent to an agent".to_string(), - )); - } - Ok(vec![UserInput::Text { - text: message, - text_elements: Vec::new(), - }]) - } - (None, Some(items)) => { - if items.is_empty() { - return Err(FunctionCallError::RespondToModel( - "Items can't be empty".to_string(), - )); - } - Ok(items) - } - } -} - -fn input_preview(items: &[UserInput]) -> String { - let parts: Vec = items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_string(), - 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(), - }) - .collect(); - - parts.join("\n") -} - -/// Builds the base config snapshot for a newly spawned sub-agent. -/// -/// The returned config starts from the parent's effective config and then refreshes the -/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, -/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; -/// skipping this helper and cloning stale config state directly can send the child agent out with -/// the wrong provider or runtime policy. -pub(crate) fn build_agent_spawn_config( - base_instructions: &BaseInstructions, - turn: &TurnContext, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - config.base_instructions = Some(base_instructions.text.clone()); - Ok(config) -} - -fn build_agent_resume_config( - turn: &TurnContext, - child_depth: i32, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - apply_spawn_agent_overrides(&mut config, child_depth); - // For resume, keep base instructions sourced from rollout/session metadata. - config.base_instructions = None; - Ok(config) -} - -fn build_agent_shared_config(turn: &TurnContext) -> Result { - let base_config = turn.config.clone(); - let mut config = (*base_config).clone(); - config.model = Some(turn.model_info.slug.clone()); - config.model_provider = turn.provider.clone(); - config.model_reasoning_effort = turn.reasoning_effort; - config.model_reasoning_summary = Some(turn.reasoning_summary); - config.developer_instructions = turn.developer_instructions.clone(); - config.compact_prompt = turn.compact_prompt.clone(); - apply_spawn_agent_runtime_overrides(&mut config, turn)?; - - Ok(config) -} - -/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. -/// -/// These values are chosen by the live turn rather than persisted config, so leaving them stale -/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. -fn apply_spawn_agent_runtime_overrides( - config: &mut Config, - turn: &TurnContext, -) -> Result<(), FunctionCallError> { - config - .permissions - .approval_policy - .set(turn.approval_policy.value()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) - })?; - config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); - config - .permissions - .sandbox_policy - .set(turn.sandbox_policy.get().clone()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) - })?; - config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); - config.permissions.network_sandbox_policy = turn.network_sandbox_policy; - Ok(()) -} - -fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { - if child_depth >= config.agent_max_depth { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } -} - -async fn apply_requested_spawn_agent_model_overrides( - session: &Session, - turn: &TurnContext, - config: &mut Config, - requested_model: Option<&str>, - requested_reasoning_effort: Option, -) -> Result<(), FunctionCallError> { - if requested_model.is_none() && requested_reasoning_effort.is_none() { - return Ok(()); - } - - if let Some(requested_model) = requested_model { - let available_models = session - .services - .models_manager - .list_models(RefreshStrategy::Offline) - .await; - let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; - let selected_model_info = session - .services - .models_manager - .get_model_info(&selected_model_name, config) - .await; - - config.model = Some(selected_model_name.clone()); - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &selected_model_name, - &selected_model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } else { - config.model_reasoning_effort = selected_model_info.default_reasoning_level; - } - - return Ok(()); - } - - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &turn.model_info.slug, - &turn.model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } - - Ok(()) -} - -fn find_spawn_agent_model_name( - available_models: &[codex_protocol::openai_models::ModelPreset], - requested_model: &str, -) -> Result { - available_models - .iter() - .find(|model| model.model == requested_model) - .map(|model| model.model.clone()) - .ok_or_else(|| { - let available = available_models - .iter() - .map(|model| model.model.as_str()) - .collect::>() - .join(", "); - FunctionCallError::RespondToModel(format!( - "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" - )) - }) -} - -fn validate_spawn_agent_reasoning_effort( - model: &str, - supported_reasoning_levels: &[ReasoningEffortPreset], - requested_reasoning_effort: ReasoningEffort, -) -> Result<(), FunctionCallError> { - if supported_reasoning_levels - .iter() - .any(|preset| preset.effort == requested_reasoning_effort) - { - return Ok(()); - } - - let supported = supported_reasoning_levels - .iter() - .map(|preset| preset.effort.to_string()) - .collect::>() - .join(", "); - Err(FunctionCallError::RespondToModel(format!( - "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" - ))) -} - #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; 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 85e879c1bb..09526182f2 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 @@ -1,5 +1,6 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use std::sync::Arc; pub(crate) struct Handler; 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 8fc4dd5155..f649482a2b 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 @@ -52,9 +52,8 @@ impl ToolHandler for Handler { .into(), ) .await; - let result = session - .services - .agent_control + let agent_control = session.services.agent_control.clone(); + let result = agent_control .send_input(receiver_thread_id, input_items) .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); 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 8458402ce5..d203e6b398 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,9 +1,11 @@ use super::*; use crate::agent::status::is_final; +use crate::error::CodexErr; use futures::FutureExt; use futures::StreamExt; use futures::stream::FuturesUnordered; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::sync::watch::Receiver; use tokio::time::Instant; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs new file mode 100644 index 0000000000..106f34253d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -0,0 +1,381 @@ +use crate::agent::AgentStatus; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; +use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use codex_features::Feature; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentStatusEntry; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; + +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; + +pub(crate) fn function_arguments(payload: ToolPayload) -> Result { + match payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string(), + )), + } +} + +pub(crate) fn tool_output_json_text(value: &T, tool_name: &str) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() + }) +} + +pub(crate) fn tool_output_response_item( + call_id: &str, + payload: &ToolPayload, + value: &T, + success: Option, + tool_name: &str, +) -> ResponseInputItem +where + T: Serialize, +{ + FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) + .to_response_item(call_id, payload) +} + +pub(crate) fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue +where + T: Serialize, +{ + serde_json::to_value(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) + }) +} + +pub(crate) fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + +pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +pub(crate) fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + +pub(crate) fn thread_spawn_source( + parent_thread_id: ThreadId, + parent_session_source: &SessionSource, + depth: i32, + agent_role: Option<&str>, + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), + })) +} + +pub(crate) fn parse_collab_input( + message: Option, + items: Option>, +) -> Result, FunctionCallError> { + match (message, items) { + (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string(), + )), + (None, None) => Err(FunctionCallError::RespondToModel( + "Provide one of: message or items".to_string(), + )), + (Some(message), None) => { + if message.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "Empty message can't be sent to an agent".to_string(), + )); + } + Ok(vec![UserInput::Text { + text: message, + text_elements: Vec::new(), + }]) + } + (None, Some(items)) => { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + Ok(items) + } + } +} + +pub(crate) fn input_preview(items: &[UserInput]) -> String { + let parts: Vec = items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + 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(), + }) + .collect(); + + parts.join("\n") +} + +/// Builds the base config snapshot for a newly spawned sub-agent. +/// +/// The returned config starts from the parent's effective config and then refreshes the +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + Ok(config) +} + +pub(crate) fn build_agent_resume_config( + turn: &TurnContext, + child_depth: i32, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + apply_spawn_agent_overrides(&mut config, child_depth); + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + config.model = Some(turn.model_info.slug.clone()); + config.model_provider = turn.provider.clone(); + config.model_reasoning_effort = turn.reasoning_effort; + config.model_reasoning_summary = Some(turn.reasoning_summary); + config.developer_instructions = turn.developer_instructions.clone(); + config.compact_prompt = turn.compact_prompt.clone(); + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale +/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +pub(crate) fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), FunctionCallError> { + config + .permissions + .approval_policy + .set(turn.approval_policy.value()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) + })?; + config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + config.cwd = turn.cwd.clone(); + config + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) + })?; + config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); + config.permissions.network_sandbox_policy = turn.network_sandbox_policy; + Ok(()) +} + +pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + if child_depth >= config.agent_max_depth { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } +} + +pub(crate) async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} 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 d8b2a713a5..873b55d660 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -7,23 +7,36 @@ use crate::codex::make_session_and_context; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; +use crate::protocol::AgentStatus; use crate::protocol::AskForApproval; +use crate::protocol::EventMsg; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::NetworkSandboxPolicy; use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::protocol::TurnCompleteEvent; +use crate::state::TaskKind; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; +use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; +use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; +use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -33,6 +46,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; fn invocation( session: Arc, @@ -68,6 +82,58 @@ fn thread_manager() -> ThreadManager { ) } +fn history_contains_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + +fn inter_agent_message_text(recipient: &str, content: &str) -> String { + format!("author: /root\nrecipient: {recipient}\nother_recipients: []\nContent: {content}") +} + +#[derive(Clone, Copy)] +struct NeverEndingTask; + +#[async_trait::async_trait] +impl SessionTask for NeverEndingTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.multi_agent_never_ending" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + cancellation_token.cancelled().await; + None + } +} + fn expect_text_output(output: T) -> (String, Option) where T: ToolOutput, @@ -286,7 +352,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -325,7 +391,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( Some("/root/test_process") ); - SendInputHandler + SendInputHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -337,6 +403,323 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( )) .await .expect("send_input should accept v2 path"); + + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == child_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/test_process" + && communication.other_recipients.is_empty() + && communication.content == "continue" + ) + })); + + let child_thread = manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let expected_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/test_process").expect("agent path"), + Vec::new(), + "continue".to_string(), + ); + timeout(Duration::from_secs(2), async { + loop { + let history_items = child_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = + history_contains_inter_agent_communication(&history_items, &expected_communication); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if recorded && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("v2 send_input should record assistant envelope"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_accepts_structured_items() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + let invocation = invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [ + {"type": "mention", "name": "drive", "path": "app://google_drive"}, + {"type": "text", "text": "read the folder"} + ] + })), + ); + + SendInputHandlerV2 + .handle(invocation) + .await + .expect("structured items should be accepted in v2"); + + let expected = Op::UserInput { + items: vec![ + UserInput::Mention { + name: "drive".to_string(), + path: "app://google_drive".to_string(), + }, + UserInput::Text { + text: "read the folder".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + }; + let captured = manager + .captured_ops() + .into_iter() + .find(|(id, op)| *id == agent_id && *op == expected); + assert_eq!(captured, Some((agent_id, expected))); + + let expected_message = inter_agent_message_text( + "/root/worker", + "[mention:$drive](app://google_drive)\nread the folder", + ); + timeout(Duration::from_secs(2), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded_assistant_envelope = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::OutputText { text } + if text == &expected_message + )) + ) + }); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } + if text == "read the folder" + || text == "[mention:$drive](app://google_drive)\nread the folder" + )) + ) + }); + if !recorded_assistant_envelope && saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("structured items should stay on the legacy user-input path"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let active_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .spawn_task( + Arc::clone(&active_turn), + vec![UserInput::Text { + text: "working".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask, + ) + .await; + + SendInputHandlerV2 + .handle(invocation( + session, + turn, + "send_input", + function_payload(json!({ + "target": agent_id.to_string(), + "message": "continue", + "interrupt": true + })), + )) + .await + .expect("interrupting v2 send_input should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(ops_for_agent.iter().any(|op| { + matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + ) + })); + assert!(!ops_for_agent.iter().any(|op| matches!( + op, + Op::UserInput { items, .. } + if items.iter().any(|item| matches!( + item, + UserInput::Text { text, .. } if text == "continue" + )) + ))); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let saw_envelope = history_contains_inter_agent_communication( + &history_items, + &InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "continue".to_string(), + ), + ); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if saw_envelope && !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("interrupting v2 send_input should preserve the redirected message"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); } #[tokio::test] @@ -356,7 +739,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() { .expect("test config should allow feature update"); turn.config = Arc::new(config); - let output = SpawnAgentHandler + let output = SpawnAgentHandlerV2 .handle(invocation( Arc::new(session), Arc::new(turn), @@ -404,7 +787,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { "task_name": "BadName" })), ); - let Err(err) = SpawnAgentHandler.handle(invocation).await else { + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { panic!("invalid agent name should be rejected"); }; assert_eq!( @@ -1023,17 +1406,17 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() { "wait_agent", function_payload(json!({"targets": [target.clone()]})), ); - let output = WaitAgentHandler + let output = WaitAgentHandlerV2 .handle(invocation) .await .expect("targets should be accepted in v2 mode"); let (content, success) = expect_text_output(output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { - status: HashMap::from([(target, AgentStatus::NotFound)]), + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), timed_out: false, } ); @@ -1201,12 +1584,7 @@ async fn wait_agent_returns_final_status_without_timeout() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - task_name: String, - } - +async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -1224,7 +1602,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -1236,9 +1614,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { )) .await .expect("spawn_agent should succeed"); - let (content, _) = expect_text_output(spawn_output); - let spawn_result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn result should parse"); + let _ = expect_text_output(spawn_output); let agent_id = session .services @@ -1268,7 +1644,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("shutdown status should arrive"); - let wait_output = WaitAgentHandler + let wait_output = WaitAgentHandlerV2 .handle(invocation( session, turn, @@ -1281,18 +1657,72 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("wait_agent should succeed"); let (content, success) = expect_text_output(wait_output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { - status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]), + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), timed_out: false, } ); assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config.clone()); + + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let child_turn = thread.thread.codex.session.new_default_turn().await; + thread + .thread + .codex + .session + .send_event( + child_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: child_turn.sub_id.clone(), + last_agent_message: Some("sensitive child output".to_string()), + }), + ) + .await; + + let output = WaitAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({ + "targets": [agent_id.to_string()], + "timeout_ms": 1000 + })), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), + timed_out: false, + } + ); + assert!(!content.contains("sensitive child output")); + assert_eq!(success, None); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs new file mode 100644 index 0000000000..931622fa0b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -0,0 +1,39 @@ +//! Implements the MultiAgentV2 collaboration tool surface. + +use crate::agent::AgentStatus; +use crate::agent::agent_resolver::resolve_agent_target; +use crate::agent::agent_resolver::resolve_agent_targets; +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::multi_agents_common::*; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use async_trait::async_trait; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::CollabAgentInteractionBeginEvent; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::user_input::UserInput; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +pub(crate) use send_input::Handler as SendInputHandler; +pub(crate) use spawn::Handler as SpawnAgentHandler; +pub(crate) use wait::Handler as WaitAgentHandler; + +mod send_input; +mod spawn; +pub(crate) mod wait; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs new file mode 100644 index 0000000000..c17e12cca8 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs @@ -0,0 +1,145 @@ +use super::*; +use codex_protocol::protocol::InterAgentCommunication; + +pub(crate) struct Handler; + +fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { + items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) +} + +#[async_trait] +impl ToolHandler for Handler { + type Output = SendInputResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SendInputArgs = parse_arguments(&arguments)?; + let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + if args.interrupt { + session + .services + .agent_control + .interrupt_agent(receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + session + .send_event( + &turn, + CollabAgentInteractionBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + prompt: prompt.clone(), + } + .into(), + ) + .await; + let result = if can_use_v2_inter_agent_instruction(&input_items) { + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "target agent is missing an agent_path".to_string(), + ) + })?; + let communication = InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + ); + session + .services + .agent_control + .send_inter_agent_communication(receiver_thread_id, communication) + .await + } else { + session + .services + .agent_control + .send_input(receiver_thread_id, input_items) + .await + } + .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + session + .send_event( + &turn, + CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + prompt, + status, + } + .into(), + ) + .await; + let submission_id = result?; + + Ok(SendInputResult { submission_id }) + } +} + +#[derive(Debug, Deserialize)] +struct SendInputArgs { + target: String, + message: Option, + items: Option>, + #[serde(default)] + interrupt: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SendInputResult { + submission_id: String, +} + +impl ToolOutput for SendInputResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "send_input") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "send_input") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "send_input") + } +} 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 new file mode 100644 index 0000000000..d67be936ec --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::agent::control::SpawnAgentOptions; +use crate::agent::next_thread_spawn_depth; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::apply_role_to_config; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = SpawnAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let result = session + .services + .agent_control + .spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + args.task_name.clone(), + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + let task_name = new_agent_path.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + task_name, + nickname, + }) + } +} + +#[derive(Debug, Deserialize)] +struct SpawnAgentArgs { + message: Option, + items: Option>, + task_name: Option, + agent_type: Option, + model: Option, + reasoning_effort: Option, + #[serde(default)] + fork_context: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SpawnAgentResult { + agent_id: Option, + task_name: Option, + nickname: Option, +} + +impl ToolOutput for SpawnAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "spawn_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "spawn_agent") + } +} 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 new file mode 100644 index 0000000000..1b1dc90e42 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::agent::status::is_final; +use futures::FutureExt; +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::Instant; +use tokio::time::timeout_at; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WaitAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: WaitArgs = parse_arguments(&arguments)?; + let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let agent_metadata = session + .services + .agent_control + .get_agent_metadata(*receiver_thread_id) + .unwrap_or_default(); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, + }); + } + + let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); + let timeout_ms = match timeout_ms { + ms if ms <= 0 => { + return Err(FunctionCallError::RespondToModel( + "timeout_ms must be greater than zero".to_owned(), + )); + } + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), + }; + + session + .send_event( + &turn, + CollabWaitingBeginEvent { + sender_thread_id: session.conversation_id, + receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), + call_id: call_id.clone(), + } + .into(), + ) + .await; + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(crate::error::CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } + } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results + }; + + let timed_out = statuses.is_empty(); + let statuses_by_id = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); + let result = WaitAgentResult::from_timed_out(timed_out); + + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id, + agent_statuses, + statuses: statuses_by_id, + } + .into(), + ) + .await; + + Ok(result) + } +} + +#[derive(Debug, Deserialize)] +struct WaitArgs { + #[serde(default)] + targets: Vec, + timeout_ms: Option, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct WaitAgentResult { + pub(crate) message: String, + pub(crate) timed_out: bool, +} + +impl WaitAgentResult { + fn from_timed_out(timed_out: bool) -> Self { + let message = if timed_out { + "Wait timed out." + } else { + "Wait completed." + }; + Self { + message: message.to_string(), + timed_out, + } + } +} + +impl ToolOutput for WaitAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "wait_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "wait_agent") + } +} + +async fn wait_for_final_status( + session: std::sync::Arc, + thread_id: ThreadId, + mut status_rx: Receiver, +) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + } +} diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index bcee62a044..37b7d015b3 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,6 +5,7 @@ use std::time::Instant; use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; +use crate::hook_runtime::run_pre_tool_use_hooks; use crate::memories::usage::emit_metric_for_tool_read; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -20,7 +21,10 @@ use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use codex_utils_readiness::Readiness; +use serde::Deserialize; use tracing::warn; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -243,6 +247,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } + if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload) + && let Some(reason) = run_pre_tool_use_hooks( + &invocation.session, + &invocation.turn, + invocation.call_id.clone(), + command.clone(), + ) + .await + { + return Err(FunctionCallError::RespondToModel(format!( + "Bash command blocked by hook: {reason}. Command: {command}" + ))); + } + let is_mutating = handler.is_mutating(&invocation).await; let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); @@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { } } +#[derive(Deserialize)] +struct PreToolUseExecCommandArgs { + cmd: String, +} + +fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option { + match (tool_name, payload) { + ("shell" | "container.exec", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) + } + ("local_shell", ToolPayload::LocalShell { params }) => Some( + codex_shell_command::parse_command::shlex_join(¶ms.command), + ), + ("shell_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.command) + } + ("exec_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.cmd) + } + _ => None, + } +} + // Hooks use a separate wire-facing input type so hook payload JSON stays stable // and decoupled from core's internal tool runtime representation. impl From<&ToolPayload> for HookToolInput { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 5d9e98df35..46e7d0c3f6 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; use async_trait::async_trait; +use codex_protocol::models::ShellToolCallParams; use pretty_assertions::assert_eq; struct TestHandler; @@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[test] +fn pre_tool_use_command_uses_raw_shell_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "command": "printf shell command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("shell_command", &payload), + Some("printf shell command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_shell_joins_vector_input() { + let payload = ToolPayload::LocalShell { + params: ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf hi".to_string(), + ], + workdir: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + additional_permissions: None, + justification: None, + }, + }; + + assert_eq!( + pre_tool_use_command("local_shell", &payload), + Some("bash -lc 'printf hi'".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_uses_raw_exec_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("exec_command", &payload), + Some("printf exec command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_skips_non_shell_tools() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ + "plan": [{ "step": "watch the tide", "status": "pending" }] + }) + .to_string(), + }; + + assert_eq!(pre_tool_use_command("update_plan", &payload), None); +} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 8544eb404a..345f7ce06f 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -4,7 +4,6 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::sandboxing::SandboxPermissions; -use crate::tools::code_mode::is_code_mode_nested_tool; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -67,7 +66,7 @@ impl ToolRouter { specs .iter() .filter_map(|configured_tool| { - if !is_code_mode_nested_tool(configured_tool.spec.name()) { + if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) { Some(configured_tool.spec.clone()) } else { None diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5ae2f333df..b115e572e6 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -11,9 +11,6 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode::WAIT_TOOL_NAME; -use crate::tools::code_mode::is_code_mode_nested_tool; -use crate::tools::code_mode::tool_description as code_mode_tool_description; -use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_description; use crate::tools::code_mode_description::augment_tool_spec_for_code_mode; use crate::tools::discoverable::DiscoverablePluginInfo; use crate::tools::discoverable::DiscoverableTool; @@ -26,9 +23,9 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +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::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; @@ -181,23 +178,41 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema() -> JsonValue { - json!({ - "type": "object", - "properties": { - "status": { - "type": "object", - "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", - "additionalProperties": agent_status_output_schema() +fn wait_output_schema(multi_agent_v2: bool) -> JsonValue { + if multi_agent_v2 { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Brief wait summary without the agent's final content." + }, + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } }, - "timed_out": { - "type": "boolean", - "description": "Whether the wait call returned due to timeout before any agent reached a final status." - } - }, - "required": ["status", "timed_out"], - "additionalProperties": false - }) + "required": ["message", "timed_out"], + "additionalProperties": false + }) + } else { + json!({ + "type": "object", + "properties": { + "status": { + "type": "object", + "description": "Final statuses keyed by canonical task name when available, otherwise by agent id.", + "additionalProperties": agent_status_output_schema() + }, + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } + }, + "required": ["status", "timed_out"], + "additionalProperties": false + }) + } } fn close_agent_output_schema() -> JsonValue { @@ -833,7 +848,7 @@ fn create_wait_tool() -> ToolSpec { name: WAIT_TOOL_NAME.to_string(), description: format!( "Waits on a yielded `{PUBLIC_TOOL_NAME}` cell and returns new output or completion.\n{}", - code_mode_wait_tool_description().trim() + codex_code_mode::build_wait_tool_description().trim() ), strict: false, parameters: JsonSchema::Object { @@ -1425,7 +1440,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn create_wait_agent_tool() -> ToolSpec { +fn create_wait_agent_tool(multi_agent_v2: bool) -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1448,8 +1463,13 @@ fn create_wait_agent_tool() -> 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(), + description: if multi_agent_v2 { + "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline." + .to_string() + } else { + "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: JsonSchema::Object { @@ -1457,7 +1477,7 @@ fn create_wait_agent_tool() -> ToolSpec { required: Some(vec!["targets".to_string()]), additional_properties: Some(false.into()), }, - output_schema: Some(wait_output_schema()), + output_schema: Some(wait_output_schema(multi_agent_v2)), }) } @@ -2176,7 +2196,10 @@ SOURCE: /[\s\S]+/ ToolSpec::Freeform(FreeformTool { name: PUBLIC_TOOL_NAME.to_string(), - description: code_mode_tool_description(enabled_tools, code_mode_only_enabled), + description: codex_code_mode::build_exec_tool_description( + enabled_tools, + code_mode_only_enabled, + ), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), @@ -2602,6 +2625,9 @@ pub(crate) fn build_specs_with_discoverable_tools( 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_v2::SendInputHandler as SendInputHandlerV2; + use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; + use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); @@ -2647,7 +2673,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolSpec::Freeform(tool) => (tool.name, tool.description), _ => return None, }; - is_code_mode_nested_tool(&name).then_some((name, description)) + codex_code_mode::is_code_mode_nested_tool(&name).then_some((name, description)) }) .collect::>(); enabled_tools.sort_by(|left, right| left.0.cmp(&right.0)); @@ -3003,7 +3029,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - create_wait_agent_tool(), + create_wait_agent_tool(config.multi_agent_v2), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -3013,9 +3039,15 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); - builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + if config.multi_agent_v2 { + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); + builder.register_handler("send_input", Arc::new(SendInputHandlerV2)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); + } else { + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); + builder.register_handler("send_input", Arc::new(SendInputHandler)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + } builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 1cb6bb1664..b33748dd35 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -469,7 +469,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_view_image_tool(config.can_request_original_image_detail), create_spawn_agent_tool(&config), create_send_input_tool(), - create_wait_agent_tool(), + create_wait_agent_tool(config.multi_agent_v2), create_close_agent_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); @@ -607,8 +607,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { .as_ref() .expect("wait_agent should define output schema"); assert_eq!( - output_schema["properties"]["status"]["description"], - json!("Final statuses keyed by canonical task name when available, otherwise by agent id.") + output_schema["properties"]["message"]["description"], + json!("Brief wait summary without the agent's final content.") ); assert_lacks_tool_name(&tools, "resume_agent"); } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..c61631e5bf 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -723,6 +723,7 @@ impl TestCodex { final_output_json_schema: None, cwd: self.config.cwd.clone(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b113fc465c..d0cefcb616 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -310,6 +310,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -919,6 +920,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1000,6 +1002,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1151,6 +1154,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1215,6 +1219,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1287,6 +1292,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1359,6 +1365,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f77697d017..6ec05bedc2 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -558,6 +558,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 23ed87aa98..278124c63a 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + let requests = server.received_requests().await.unwrap_or_default(); + assert!(requests.is_empty(), "expected no refresh token requests"); + + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_expired" + } + }))) + .expect(0) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let stale_refresh = Utc::now() - Duration::days(9); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens), + last_refresh: Some(stale_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let fresh_refresh = Utc::now() - Duration::days(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should reload from disk")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3ea30c5967..5da71f556b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1310,6 +1310,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re }], cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), @@ -1427,6 +1428,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() }], cwd: config.cwd.clone(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model, effort: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index c74de38e86..fa8229ecc3 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -1672,8 +1672,6 @@ async fn code_mode_exit_stops_script_immediately() -> Result<()> { &server, "use exec to stop script early with exit helper", r#" -import { exit, text } from "@openai/code_mode"; - text("before"); exit(); text("after"); @@ -2129,6 +2127,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "SuppressedError", "Symbol", "SyntaxError", + "Temporal", "TypeError", "URIError", "Uint16Array", @@ -2141,7 +2140,6 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "WebAssembly", "__codexContentItems", "add_content", - "console", "decodeURI", "decodeURIComponent", "encodeURI", @@ -2282,10 +2280,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { test.session_configured = new_thread.session_configured; let code = r#" -import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js"; - const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool"); -const out = await hidden_dynamic_tool({ city: "Paris" }); +const out = await tools.hidden_dynamic_tool({ city: "Paris" }); text( JSON.stringify({ name: tool?.name ?? null, @@ -2323,6 +2319,7 @@ text( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 81d0678cad..1517d9968c 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -178,6 +178,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { }], cwd: test.config.cwd.clone(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, @@ -293,6 +294,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu }], cwd: test.config.cwd.clone(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 2f4365a959..a54d740111 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1658,6 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: resumed.session_configured.model.clone(), effort: None, @@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1772,6 +1774,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -1882,6 +1885,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: initial.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1930,6 +1934,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -3132,6 +3137,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -3156,6 +3162,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fafbced0b7..2007aab55b 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -12,10 +12,14 @@ use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; use anyhow::Result; use codex_core::CodexThread; +use codex_core::ForkSnapshot; use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::WarningEvent; @@ -380,8 +384,13 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; let summary_after_second_compact = extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT); - let mut expected_after_second_compact_user_texts = - vec!["AFTER_FORK".to_string(), summary_after_second_compact]; + let mut expected_after_second_compact_user_texts = vec![ + "hello world".to_string(), + "AFTER_COMPACT".to_string(), + "AFTER_RESUME".to_string(), + "AFTER_FORK".to_string(), + summary_after_second_compact, + ]; expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix); expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string()); let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user"); @@ -502,6 +511,143 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +/// Scenario: rolling back a turn that introduced persistent pre-turn context +/// diffs currently duplicates those context updates on the next request. +async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> { + if network_disabled() { + println!("Skipping test because network is disabled in this sandbox"); + return Ok(()); + } + + const MODEL: &str = "gpt-5.1-codex"; + const TURN_ONE_USER: &str = "turn 1 user"; + const TURN_TWO_USER: &str = "turn 2 user"; + const FOLLOWUP_USER: &str = "follow-up user"; + const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS"; + const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD"; + + let server = MockServer::start().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "turn 1 assistant"), + ev_completed("r1"), + ]), + sse(vec![ + ev_assistant_message("m2", "turn 2 assistant"), + ev_completed("r2"), + ]), + sse(vec![ev_completed("r3")]), + ], + ) + .await; + + let (_home, config, _manager, conversation) = + start_test_conversation(&server, Some(MODEL)).await; + + user_turn(&conversation, TURN_ONE_USER).await; + + let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); + std::fs::create_dir_all(&override_cwd)?; + conversation + .submit(Op::OverrideTurnContext { + cwd: Some(override_cwd), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: MODEL.to_string(), + reasoning_effort: None, + developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()), + }, + }), + personality: None, + }) + .await?; + + user_turn(&conversation, TURN_TWO_USER).await; + + conversation + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + let rollback_event = wait_for_event(&conversation, |ev| { + matches!(ev, EventMsg::ThreadRolledBack(_)) + }) + .await; + let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else { + panic!("expected thread rolled back event"); + }; + assert_eq!(rollback_event.num_turns, 1); + + user_turn(&conversation, FOLLOWUP_USER).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + + assert_eq!( + requests[1] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(), + 1 + ); + assert_eq!( + requests[1] + .message_input_texts("user") + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 1 + ); + assert_eq!( + requests[2] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(), + 2 + ); + + let after_rollback_user_texts = requests[2].message_input_texts("user"); + assert_eq!( + after_rollback_user_texts + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 2 + ); + assert_eq!( + after_rollback_user_texts.last().map(String::as_str), + Some(FOLLOWUP_USER) + ); + + insta::assert_snapshot!( + "rollback_followup_turn_duplicates_context_updates", + context_snapshot::format_labeled_requests_snapshot( + "rollback currently duplicates pre-turn override context updates on the follow-up request", + &[ + ("rolled-back turn request", &requests[1]), + ("follow-up request after rollback", &requests[2]), + ], + &ContextSnapshotOptions::default() + .strip_capability_instructions() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }), + ) + ); + + Ok(()) +} + fn normalize_line_endings(value: &mut Value) { match value { Value::String(text) => { @@ -701,8 +847,14 @@ async fn fork_thread( path: std::path::PathBuf, nth_user_message: usize, ) -> Arc { - Box::pin(manager.fork_thread(nth_user_message, config.clone(), path, false, None)) - .await - .expect("fork conversation") - .thread + Box::pin(manager.fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message), + config.clone(), + path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + )) + .await + .expect("fork conversation") + .thread } diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 18be468020..fb055c970f 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -51,6 +51,7 @@ async fn submit_user_turn( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, @@ -131,6 +132,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96fef440c8..a16e460204 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,3 +1,4 @@ +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::parse_turn_item; use codex_protocol::items::TurnItem; @@ -110,7 +111,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork1, .. } = thread_manager - .fork_thread(1, config_for_fork.clone(), base_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(1), + config_for_fork.clone(), + base_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 1"); @@ -129,7 +136,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork2, .. } = thread_manager - .fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(0), + config_for_fork.clone(), + fork1_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 2"); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c7042dd251..c7de5dcb26 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_pre_tool_use_hook( + home: &Path, + matcher: Option<&str>, + mode: &str, + reason: &str, +) -> Result<()> { + let script_path = home.join("pre_tool_use_hook.py"); + let log_path = home.join("pre_tool_use_hook_log.jsonl"); + let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?; + let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +mode = {mode_json} +reason = {reason_json} + +payload = json.load(sys.stdin) + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if mode == "json_deny": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + }} + }})) +elif mode == "exit_2": + sys.stderr.write(reason + "\n") + raise SystemExit(2) +"#, + log_path = log_path.display(), + mode_json = mode_json, + reason_json = reason_json, + ); + + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running pre tool use hook", + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = Value::String(matcher.to_string()); + } + + let hooks = serde_json::json!({ + "hooks": { + "PreToolUse": [group] + } + }); + + fs::write(&script_path, script).context("write pre tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> { let script_path = home.join("session_start_hook.py"); let log_path = home.join("session_start_hook_log.jsonl"); @@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result> { .collect() } +fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl")) + .context("read pre tool use hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse pre tool use hook log line")) + .collect() +} + fn read_session_start_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("session_start_hook_log.jsonl")) .context("read session start hook log")? @@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command"; + let marker = std::env::temp_dir().join("pretooluse-shell-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover pre tool use marker")?; + } + + test.submit_turn_with_policy( + "run the blocked shell command", + codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked by pre hook"), + "blocked tool output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked tool output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked command should not create marker file" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + let transcript_path = hook_inputs[0]["transcript_path"] + .as_str() + .expect("pre tool use hook transcript_path"); + assert!( + !transcript_path.is_empty(), + "pre tool use hook should receive a non-empty transcript_path", + ); + assert!( + Path::new(transcript_path).exists(), + "pre tool use hook transcript_path should be materialized on disk", + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-local-shell"; + let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("printf blocked > {}", marker.display()), + ]; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_local_shell_call( + call_id, + "completed", + command.iter().map(String::as_str).collect(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local shell blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover local shell marker")?; + } + + test.submit_turn("run the blocked local shell command") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("local shell output string"); + assert!( + output.contains("Bash command blocked by hook: blocked local shell"), + "blocked local shell output should surface the hook reason", + ); + assert!( + output.contains(&format!( + "Command: {}", + codex_shell_command::parse_command::shlex_join(&command) + )), + "blocked local shell output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked local shell command should not execute" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + codex_shell_command::parse_command::shlex_join(&command), + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-exec-command"; + let marker = std::env::temp_dir().join("pretooluse-exec-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "cmd": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "exec command blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover exec marker")?; + } + + test.submit_turn("run the blocked exec command").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked exec command"), + "blocked exec command output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked exec command output should surface the blocked command", + ); + assert!(!marker.exists(), "blocked exec command should not execute"); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-update-plan"; + let args = serde_json::json!({ + "plan": [{ + "step": "watch the tide", + "status": "pending", + }] + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "update_plan", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "plan updated"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("update the plan").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("update plan output string"); + assert!( + !output.contains("should not fire"), + "non-shell tool output should not be blocked by PreToolUse", + ); + + let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl"); + assert!( + !hook_log_path.exists(), + "non-shell tools should not trigger pre tool use hooks", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 07b51c7629..1526fe11a2 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -122,6 +122,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index d860545553..f949cd4b97 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -522,6 +522,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -598,6 +599,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -706,6 +708,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -892,6 +895,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -1005,6 +1009,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index f7dfd02daf..3b6f3e3f9c 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -80,6 +80,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { 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: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 2192bdffd9..663cf47488 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -61,6 +61,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 9902f0ee6c..a7c5ceb0d2 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -126,6 +126,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -162,6 +163,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -221,6 +223,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -257,6 +260,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -398,6 +402,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -525,6 +531,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -545,6 +552,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -655,6 +663,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -675,6 +684,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -787,6 +797,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -815,6 +826,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -969,6 +981,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -1027,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index a10fa7c262..49f635432f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -121,6 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: preturn_context_diff_cwd, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -222,6 +224,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_one.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -245,6 +248,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_two, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -354,6 +358,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul final_output_json_schema: None, cwd: resume_override_cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7cb7573347..3d5904bd7a 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -96,6 +96,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index a479ebb80c..272c820caf 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -102,6 +102,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index ecf6283664..96df3fd2f1 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .features .disable(Feature::GhostCommit) .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); }) .build(&server) .await @@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; logs_assert(|lines: &[&str]| { let line = lines diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index cdf69acdc0..30180c8cf6 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::ForkSnapshot; use codex_core::config::Constrained; use codex_execpolicy::Policy; use codex_protocol::models::DeveloperInstructions; @@ -419,7 +420,13 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .fork_thread( + ForkSnapshot::Interrupted, + fork_config, + rollout_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await?; forked .thread @@ -493,6 +500,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = DeveloperInstructions::from_policy( &sandbox_policy, AskForApproval::OnRequest, + test.config.approvals_reviewer, &Policy::empty(), test.config.cwd.as_path(), false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9a495e7af4..ed4da07168 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -103,6 +103,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -153,6 +154,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -210,6 +212,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -273,6 +276,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -324,6 +328,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -361,6 +366,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -427,6 +433,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -543,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -580,6 +589,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -696,6 +706,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -814,6 +825,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -851,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 14caaf8f0b..9be83e6bb0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -703,6 +703,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], cwd: new_cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: new_policy.clone(), model: "o3".to_string(), effort: Some(ReasoningEffort::High), @@ -815,6 +816,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -835,6 +837,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -939,6 +942,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }], cwd: default_cwd.clone(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, @@ -959,6 +963,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }], cwd: default_cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: "o3".to_string(), effort: Some(ReasoningEffort::High), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 860d83fe9a..83d911533d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -173,6 +173,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -231,6 +232,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -394,6 +396,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REMOTE_MODEL_SLUG.to_string(), effort: None, @@ -612,6 +615,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..7c16599bf0 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -193,6 +193,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..14506f4a41 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -145,6 +145,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1bf759d270..8e30b37c21 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -255,6 +256,7 @@ where final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 772674f79a..6cbf9521ba 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -129,6 +129,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -298,6 +299,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -501,6 +503,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_only_model_slug.to_string(), effort: None, @@ -615,6 +618,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -776,6 +780,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -1022,6 +1027,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index eabbdc9d03..51a88ef16a 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -45,6 +45,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -143,6 +144,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -228,6 +230,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -277,6 +280,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 68228a412e..0df7d583e5 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -164,6 +164,7 @@ async fn run_snapshot_command_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -254,6 +255,7 @@ async fn run_shell_command_snapshot_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -324,6 +326,7 @@ async fn run_tool_turn_on_harness( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -528,7 +531,9 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n"; let args = json!({ "command": script, - "timeout_ms": 1_000, + // The intercepted apply_patch path self-invokes codex, which can take + // longer than a second in Bazel macOS test environments. + "timeout_ms": 5_000, }); let call_id = "shell-snapshot-apply-patch"; let responses = vec![ @@ -555,6 +560,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { final_output_json_schema: None, cwd: cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index b5fda12ae0..5a50e09cee 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -65,6 +65,7 @@ async fn submit_turn_with_policies( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 801e0dd6bb..388618b56a 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -74,6 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap new file mode 100644 index 0000000000..676421c373 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact_resume_fork.rs +expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)" +--- +Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request + +## rolled-back turn request +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/user:turn 2 user + +## follow-up request after rollback +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +07:message/user: +08:message/user:follow-up user diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 248ada02cc..2df92dbf95 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -395,6 +395,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index bb1da9e8b1..9594195a5b 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -85,6 +85,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -155,6 +156,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -330,6 +333,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -433,6 +437,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index a177a6ee37..faff0b2e09 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -42,6 +42,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -358,6 +359,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index e7b25799ef..f10567de24 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -486,6 +486,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 1e6073be09..c07fd20e5e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -159,7 +159,9 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let call_id = "uexec-apply-patch"; let args = json!({ "cmd": command, - "yield_time_ms": 250, + // The intercepted apply_patch path spawns a helper process, which can + // take longer than a tiny unified-exec yield deadline on CI. + "yield_time_ms": 5_000, }); let responses = vec![ @@ -190,6 +192,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -323,6 +326,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -405,6 +409,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -490,6 +495,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -587,6 +593,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -666,6 +673,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -746,6 +754,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -880,6 +889,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1021,6 +1031,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1185,6 +1196,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1287,6 +1299,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1409,6 +1422,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1503,6 +1517,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1588,6 +1603,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1723,6 +1739,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1895,6 +1912,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1976,6 +1994,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2068,6 +2087,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2176,6 +2196,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2315,6 +2336,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2433,6 +2455,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2533,6 +2556,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2632,6 +2656,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, @@ -2741,6 +2766,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -2840,6 +2866,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2979,6 +3006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index eb593c6fe3..801ff76274 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -176,6 +176,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: fixture.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index efc2e53324..732d6b29eb 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -170,6 +170,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -278,6 +279,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -418,6 +420,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -516,6 +519,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -607,6 +611,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -706,6 +711,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -816,6 +822,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -917,6 +924,7 @@ await codex.emitImage(out); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1036,6 +1044,7 @@ console.log(out.type); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1128,6 +1137,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1203,6 +1213,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1283,6 +1294,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1405,6 +1417,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model_slug.to_string(), effort: None, @@ -1479,6 +1492,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> final_output_json_schema: None, cwd: config.cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 0090093c77..e7f33df148 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -156,6 +156,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 4d4e5c147c..9f238b40ee 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -65,7 +65,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types: - `Op` - - `Op::UserTurn` – Any input from the user to kick off a `Turn` + - `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer` - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution 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 0e49166b81..2b935fce95 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput { fn hook_event_name(event_name: HookEventName) -> &'static str { match event_name { + HookEventName::PreToolUse => "PreToolUse", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f648a63952..c10b86f696 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -669,7 +669,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Print the effective configuration and initial request so users can see what Codex // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); - if !json_mode && let Some(message) = codex_core::config::missing_system_bwrap_warning() { + if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { let _ = event_processor.process_event(Event { id: String::new(), msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json new file mode 100644 index 0000000000..86dfac165c --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PreToolUseToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": { + "$ref": "#/definitions/PreToolUseToolInput" + }, + "tool_name": { + "const": "Bash", + "type": "string" + }, + "tool_use_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": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "pre-tool-use.command.input", + "type": "object" +} \ No newline at end of file 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 new file mode 100644 index 0000000000..0992983fe4 --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop" + ], + "type": "string" + }, + "PreToolUseDecisionWire": { + "enum": [ + "approve", + "block" + ], + "type": "string" + }, + "PreToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + }, + "permissionDecision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + } + ], + "default": null + }, + "permissionDecisionReason": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + }, + "PreToolUsePermissionDecisionWire": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-tool-use.command.output", + "type": "object" +} \ No newline at end of file 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 292777ff67..f44928983d 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 @@ -4,6 +4,7 @@ "definitions": { "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" 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 c6935aa6da..27878752c1 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 @@ -10,6 +10,7 @@ }, "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 0d9357e392..1a2d962bc8 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,6 +8,8 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index db0f38c645..55dfca63cd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -3,11 +3,12 @@ use std::path::Path; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use regex::Regex; use super::ConfiguredHandler; use super::config::HookHandlerConfig; use super::config::HooksFile; +use crate::events::common::matcher_pattern_for_event; +use crate::events::common::validate_matcher_pattern; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - } }; + for group in parsed.hooks.pre_tool_use { + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + source_path.as_path(), + codex_protocol::protocol::HookEventName::PreToolUse, + matcher_pattern_for_event( + codex_protocol::protocol::HookEventName::PreToolUse, + group.matcher.as_deref(), + ), + group.hooks, + ); + } + for group in parsed.hooks.session_start { append_group_handlers( &mut handlers, @@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::SessionStart, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::SessionStart, group.matcher.as_deref(), ), @@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::UserPromptSubmit, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::UserPromptSubmit, group.matcher.as_deref(), ), @@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::Stop, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::Stop, group.matcher.as_deref(), ), @@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - DiscoveryResult { handlers, warnings } } -fn effective_matcher( - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, -) -> Option<&str> { - match event_name { - codex_protocol::protocol::HookEventName::SessionStart => matcher, - codex_protocol::protocol::HookEventName::UserPromptSubmit - | codex_protocol::protocol::HookEventName::Stop => None, - } -} - fn append_group_handlers( handlers: &mut Vec, warnings: &mut Vec, @@ -139,7 +144,7 @@ fn append_group_handlers( group_handlers: Vec, ) { if let Some(matcher) = matcher - && let Err(err) = Regex::new(matcher) + && let Err(err) = validate_matcher_pattern(matcher) { warnings.push(format!( "invalid matcher {matcher:?} in {}: {err}", @@ -205,7 +210,7 @@ mod tests { use super::ConfiguredHandler; use super::HookHandlerConfig; use super::append_group_handlers; - use super::effective_matcher; + use crate::events::common::matcher_pattern_for_event; #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { @@ -219,7 +224,7 @@ mod tests { &mut display_order, Path::new("/tmp/hooks.json"), HookEventName::UserPromptSubmit, - effective_matcher(HookEventName::UserPromptSubmit, Some("[")), + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")), vec![HookHandlerConfig::Command { command: "echo hello".to_string(), timeout_sec: None, @@ -242,4 +247,66 @@ mod tests { }] ); } + + #[test] + fn pre_tool_use_keeps_valid_matcher_during_discovery() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + handlers, + vec![ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hello".to_string(), + timeout_sec: 600, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + }] + ); + } + + #[test] + fn pre_tool_use_treats_star_matcher_as_match_all() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(handlers[0].matcher.as_deref(), Some("*")); + } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index e316d9af98..0b29e12fa0 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -14,6 +14,7 @@ use super::CommandShell; use super::ConfiguredHandler; use super::command_runner::CommandRunResult; use super::command_runner::run_command; +use crate::events::common::matches_matcher; #[derive(Debug)] pub(crate) struct ParsedHandler { @@ -30,13 +31,9 @@ pub(crate) fn select_handlers( .iter() .filter(|handler| handler.event_name == event_name) .filter(|handler| match event_name { - HookEventName::SessionStart => match (&handler.matcher, matcher_input) { - (Some(matcher), Some(input)) => regex::Regex::new(matcher) - .map(|regex| regex.is_match(input)) - .unwrap_or(false), - (None, _) => true, - _ => false, - }, + HookEventName::PreToolUse | HookEventName::SessionStart => { + matches_matcher(handler.matcher.as_deref(), matcher_input) + } HookEventName::UserPromptSubmit | HookEventName::Stop => true, }) .cloned() @@ -109,7 +106,9 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart => HookScope::Thread, - HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, + HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => { + HookScope::Turn + } } } @@ -172,6 +171,50 @@ mod tests { assert_eq!(selected[1].display_order, 1); } + #[test] + fn pre_tool_use_matches_tool_name() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_star_matcher_matches_all_tools() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_regex_alternation_matches_each_tool_name() { + let handlers = vec![make_handler( + HookEventName::PreToolUse, + Some("Edit|Write"), + "echo same", + 0, + )]; + + let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit")); + let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write")); + let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected_edit.len(), 1); + assert_eq!(selected_write.len(), 1); + assert_eq!(selected_bash.len(), 0); + } + #[test] fn user_prompt_submit_ignores_matcher() { let handlers = vec![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 24ff72990e..f54403b74b 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -46,6 +48,7 @@ impl ConfiguredHandler { fn event_name_label(&self) -> &'static str { match self.event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", @@ -105,6 +108,10 @@ impl ClaudeHooksEngine { crate::events::session_start::preview(&self.handlers, request) } + pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec { + crate::events::pre_tool_use::preview(&self.handlers, request) + } + pub(crate) async fn run_session_start( &self, request: SessionStartRequest, @@ -113,6 +120,10 @@ impl ClaudeHooksEngine { crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await } + pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await + } + pub(crate) fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index d72ae07155..3fc0e7a0ba 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput { pub additional_context: Option, } +#[derive(Debug, Clone)] +pub(crate) struct PreToolUseOutput { + pub universal: UniversalOutput, + pub block_reason: Option, + pub invalid_reason: Option, +} + #[derive(Debug, Clone)] pub(crate) struct UserPromptSubmitOutput { pub universal: UniversalOutput, @@ -31,6 +38,9 @@ pub(crate) struct StopOutput { use crate::schema::BlockDecisionWire; use crate::schema::HookUniversalOutputWire; +use crate::schema::PreToolUseCommandOutputWire; +use crate::schema::PreToolUseDecisionWire; +use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; @@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option { }) } +pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { + let PreToolUseCommandOutputWire { + universal: universal_wire, + decision, + reason, + hook_specific_output, + } = parse_json(stdout)?; + let universal = UniversalOutput::from(universal_wire); + let hook_specific_output = hook_specific_output.as_ref(); + let use_hook_specific_decision = hook_specific_output.is_some_and(|output| { + output.permission_decision.is_some() + || output.permission_decision_reason.is_some() + || output.updated_input.is_some() + || output.additional_context.is_some() + }); + let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| { + if use_hook_specific_decision { + hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output) + } else { + unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref()) + } + }); + let block_reason = if invalid_reason.is_none() { + if use_hook_specific_decision { + hook_specific_output.and_then(|output| match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Deny) => output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason), + _ => None, + }) + } else { + match decision.as_ref() { + Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason), + Some(PreToolUseDecisionWire::Approve) | None => None, + } + } + } else { + None + }; + + Some(PreToolUseOutput { + universal, + block_reason, + invalid_reason, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); @@ -119,3 +177,97 @@ where fn invalid_block_message(event_name: &str) -> String { format!("{event_name} hook returned decision:block without a non-empty reason") } + +fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option { + if !universal.continue_processing { + Some("PreToolUse hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("PreToolUse hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("PreToolUse hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + +fn unsupported_pre_tool_use_hook_specific_output( + output: &crate::schema::PreToolUseHookSpecificOutputWire, +) -> Option { + if output.updated_input.is_some() { + Some("PreToolUse hook returned unsupported updatedInput".to_string()) + } else if output + .additional_context + .as_deref() + .and_then(trimmed_reason) + .is_some() + { + Some("PreToolUse hook returned unsupported additionalContext".to_string()) + } else { + match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Allow) => { + Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Ask) => { + Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Deny) => { + if output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason) + .is_none() + { + Some(invalid_pre_tool_use_reason_message()) + } else { + None + } + } + None => { + if output.permission_decision_reason.is_some() { + Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string()) + } else { + None + } + } + } + } +} + +fn unsupported_pre_tool_use_legacy_decision( + decision: Option<&PreToolUseDecisionWire>, + reason: Option<&str>, +) -> Option { + match decision { + Some(PreToolUseDecisionWire::Approve) => { + Some("PreToolUse hook returned unsupported decision:approve".to_string()) + } + Some(PreToolUseDecisionWire::Block) => { + if reason.and_then(trimmed_reason).is_none() { + Some(invalid_block_message("PreToolUse")) + } else { + None + } + } + None => { + if reason.is_some() { + Some("PreToolUse hook returned reason without decision".to_string()) + } else { + None + } + } + } +} + +fn invalid_pre_tool_use_reason_message() -> String { + "PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason" + .to_string() +} + +fn trimmed_reason(reason: &str) -> Option { + let trimmed = reason.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 2ad54e5062..1a51e59527 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -4,6 +4,8 @@ use serde_json::Value; #[allow(dead_code)] pub(crate) struct GeneratedHookSchemas { + pub pre_tool_use_command_input: Value, + pub pre_tool_use_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, pub user_prompt_submit_command_input: Value, @@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas { pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { static SCHEMAS: OnceLock = OnceLock::new(); SCHEMAS.get_or_init(|| GeneratedHookSchemas { + pre_tool_use_command_input: parse_json_schema( + "pre-tool-use.command.input", + include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"), + ), + pre_tool_use_command_output: parse_json_schema( + "pre-tool-use.command.output", + include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"), + ), session_start_command_input: parse_json_schema( "session-start.command.input", include_str!("../../schema/generated/session-start.command.input.schema.json"), @@ -56,6 +66,8 @@ mod tests { fn loads_generated_hook_schemas() { let schemas = generated_hook_schemas(); + assert_eq!(schemas.pre_tool_use_command_input["type"], "object"); + assert_eq!(schemas.pre_tool_use_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.user_prompt_submit_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index b6358e068a..b4c274bbd3 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -1,4 +1,5 @@ use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; @@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events( }) .collect() } + +pub(crate) fn matcher_pattern_for_event( + event_name: HookEventName, + matcher: Option<&str>, +) -> Option<&str> { + match event_name { + HookEventName::PreToolUse | HookEventName::SessionStart => matcher, + HookEventName::UserPromptSubmit | HookEventName::Stop => None, + } +} + +pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> { + if is_match_all_matcher(matcher) { + return Ok(()); + } + regex::Regex::new(matcher).map(|_| ()) +} + +pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool { + match matcher { + None => true, + Some(matcher) if is_match_all_matcher(matcher) => true, + Some(matcher) => input + .and_then(|input| { + regex::Regex::new(matcher) + .ok() + .map(|regex| regex.is_match(input)) + }) + .unwrap_or(false), + } +} + +fn is_match_all_matcher(matcher: &str) -> bool { + matcher.is_empty() || matcher == "*" +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::HookEventName; + use pretty_assertions::assert_eq; + + use super::matcher_pattern_for_event; + use super::matches_matcher; + use super::validate_matcher_pattern; + + #[test] + fn matcher_omitted_matches_all_occurrences() { + assert!(matches_matcher(None, Some("Bash"))); + assert!(matches_matcher(None, Some("Write"))); + } + + #[test] + fn matcher_star_matches_all_occurrences() { + assert!(matches_matcher(Some("*"), Some("Bash"))); + assert!(matches_matcher(Some("*"), Some("Edit"))); + assert_eq!(validate_matcher_pattern("*"), Ok(())); + } + + #[test] + fn matcher_empty_string_matches_all_occurrences() { + assert!(matches_matcher(Some(""), Some("Bash"))); + assert!(matches_matcher(Some(""), Some("SessionStart"))); + assert_eq!(validate_matcher_pattern(""), Ok(())); + } + + #[test] + fn matcher_uses_regex_matching() { + assert!(matches_matcher(Some("Edit|Write"), Some("Edit"))); + assert!(matches_matcher(Some("Edit|Write"), Some("Write"))); + assert!(!matches_matcher(Some("Edit|Write"), Some("Bash"))); + assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(())); + } + + #[test] + fn matcher_supports_anchored_regexes() { + assert!(matches_matcher(Some("^Bash$"), Some("Bash"))); + assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput"))); + assert_eq!(validate_matcher_pattern("^Bash$"), Ok(())); + } + + #[test] + fn invalid_regex_is_rejected() { + assert!(validate_matcher_pattern("[").is_err()); + assert!(!matches_matcher(Some("["), Some("Bash"))); + } + + #[test] + fn unsupported_events_ignore_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")), + None + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::Stop, Some("^done$")), + None + ); + } + + #[test] + fn supported_events_keep_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")), + Some("Bash") + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")), + Some("startup|resume") + ); + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 3bb54699af..603395eb5a 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -1,4 +1,5 @@ -mod common; +pub(crate) mod common; +pub mod pre_tool_use; pub mod session_start; pub mod stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs new file mode 100644 index 0000000000..8366bb632c --- /dev/null +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -0,0 +1,479 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::engine::output_parser; +use crate::schema::PreToolUseCommandInput; + +#[derive(Debug, Clone)] +pub struct PreToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, +} + +#[derive(Debug)] +pub struct PreToolUseOutcome { + pub hook_events: Vec, + pub should_block: bool, + pub block_reason: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PreToolUseHandlerData { + should_block: bool, + block_reason: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &PreToolUseRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PreToolUseRequest, +) -> PreToolUseOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ); + if matched.is_empty() { + return PreToolUseOutcome { + hook_events: Vec::new(), + should_block: false, + block_reason: None, + }; + } + + let input_json = match serde_json::to_string(&PreToolUseCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "PreToolUse".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + tool_name: "Bash".to_string(), + tool_input: crate::schema::PreToolUseToolInput { + command: request.command.clone(), + }, + tool_use_id: request.tool_use_id.clone(), + }) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize pre tool use hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + + let should_block = results.iter().any(|result| result.data.should_block); + let block_reason = results + .iter() + .find_map(|result| result.data.block_reason.clone()); + + PreToolUseOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + should_block, + block_reason, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut should_block = false; + let mut block_reason = None; + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(reason) = parsed.block_reason { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } + } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }); + } + } + Some(2) => { + if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: PreToolUseHandlerData { + should_block, + block_reason, + }, + } +} + +fn serialization_failure_outcome(hook_events: Vec) -> PreToolUseOutcome { + PreToolUseOutcome { + hook_events, + should_block: false, + block_reason: None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookOutputEntry; + use codex_protocol::protocol::HookOutputEntryKind; + use codex_protocol::protocol::HookRunStatus; + use pretty_assertions::assert_eq; + + use super::PreToolUseHandlerData; + use super::parse_completed; + use crate::engine::ConfiguredHandler; + use crate::engine::command_runner::CommandRunResult; + + #[test] + fn permission_decision_deny_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn deprecated_block_decision_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"decision":"block","reason":"do not run that"}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn unsupported_permission_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(), + }] + ); + } + + #[test] + fn deprecated_approve_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), r#"{"decision":"approve"}"#, ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported decision:approve".to_string(), + }] + ); + } + + #[test] + fn unsupported_additional_context_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported additionalContext".to_string(), + }] + ); + } + + #[test] + fn plain_stdout_is_ignored() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "hook ran successfully\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!(parsed.completed.run.entries, vec![]); + } + + #[test] + fn invalid_json_like_stdout_fails_instead_of_becoming_noop() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "{\"decision\":\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }] + ); + } + + #[test] + fn exit_code_two_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result(Some(2), "", "blocked by policy\n"), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("blocked by policy".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "blocked by policy".to_string(), + }] + ); + } + + fn handler() -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hook".to_string(), + timeout_sec: 5, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + } + } + + fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> CommandRunResult { + CommandRunResult { + started_at: 1, + completed_at: 2, + duration_ms: 1, + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + error: None, + } + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 768a24c5e3..86b39b0b7d 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,8 @@ mod registry; mod schema; mod types; +pub use events::pre_tool_use::PreToolUseOutcome; +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; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 3b63bda8c3..440af648dd 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,8 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -92,6 +94,13 @@ impl Hooks { self.engine.preview_session_start(request) } + pub fn preview_pre_tool_use( + &self, + request: &PreToolUseRequest, + ) -> Vec { + self.engine.preview_pre_tool_use(request) + } + pub async fn run_session_start( &self, request: SessionStartRequest, @@ -100,6 +109,10 @@ impl Hooks { self.engine.run_session_start(request, turn_id).await } + pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + self.engine.run_pre_tool_use(request).await + } + pub fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 067658541a..277500984c 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -13,6 +13,8 @@ use std::path::Path; use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; +const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json"; +const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; 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"; @@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum HookEventNameWire { + #[serde(rename = "PreToolUse")] + PreToolUse, #[serde(rename = "SessionStart")] SessionStart, #[serde(rename = "UserPromptSubmit")] @@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire { Stop, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.output")] +pub(crate) struct PreToolUseCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub permission_decision: Option, + #[serde(default)] + pub permission_decision_reason: Option, + #[serde(default)] + pub updated_input: Option, + #[serde(default)] + pub additional_context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUsePermissionDecisionWire { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUseDecisionWire { + #[serde(rename = "approve")] + Approve, + #[serde(rename = "block")] + Block, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseToolInput { + pub command: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.input")] +pub(crate) struct PreToolUseCommandInput { + 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 = "pre_tool_use_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "pre_tool_use_tool_name_schema")] + pub tool_name: String, + pub tool_input: PreToolUseToolInput, + pub tool_use_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(SESSION_START_INPUT_FIXTURE), schema_json::()?, @@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SessionStart") } +fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("PreToolUse") +} + +fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("Bash") +} + fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("UserPromptSubmit") } @@ -346,6 +441,9 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::PRE_TOOL_USE_INPUT_FIXTURE; + use super::PRE_TOOL_USE_OUTPUT_FIXTURE; + use super::PreToolUseCommandInput; use super::SESSION_START_INPUT_FIXTURE; use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; @@ -362,6 +460,12 @@ mod tests { fn expected_fixture(name: &str) -> &'static str { match name { + PRE_TOOL_USE_INPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.input.schema.json") + } + PRE_TOOL_USE_OUTPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.output.schema.json") + } SESSION_START_INPUT_FIXTURE => { include_str!("../schema/generated/session-start.command.input.schema.json") } @@ -395,6 +499,8 @@ mod tests { write_schema_fixtures(&schema_root).expect("write generated hook schemas"); for fixture in [ + PRE_TOOL_USE_INPUT_FIXTURE, + PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, @@ -414,6 +520,10 @@ mod tests { fn turn_scoped_hook_inputs_include_codex_turn_id_extension() { // Codex intentionally diverges from Claude's public hook docs here so // internal hook consumers can key off the active turn. + let pre_tool_use: Value = serde_json::from_slice( + &schema_json::().expect("serialize pre tool use input schema"), + ) + .expect("parse pre tool use input schema"); let user_prompt_submit: Value = serde_json::from_slice( &schema_json::() .expect("serialize user prompt submit input schema"), @@ -424,7 +534,7 @@ mod tests { ) .expect("parse stop input schema"); - for schema in [&user_prompt_submit, &stop] { + for schema in [&pre_tool_use, &user_prompt_submit, &stop] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); assert!( schema["required"] diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 3fde7d9738..ac34fb4edc 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -8,19 +8,23 @@ This crate is responsible for producing: - this should also be true of the `codex` multitool CLI On Linux, the bubblewrap pipeline prefers the system `/usr/bin/bwrap` whenever -it is available. If `/usr/bin/bwrap` is missing, the helper still falls back to +it is available and supports the required argv-rewrite flags. If `/usr/bin/bwrap` +is missing or too old to support the required flags, the helper falls back to the vendored bubblewrap path compiled into this binary. -Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing so users -know it is falling back to the vendored helper. +Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing or too +old to support the required flags so users know it is falling back to the +vendored helper. **Current Behavior** - Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported. - Bubblewrap is the default filesystem sandbox pipeline. -- If `/usr/bin/bwrap` is present, the helper uses it. -- If `/usr/bin/bwrap` is missing, the helper falls back to the vendored - bubblewrap path. -- If `/usr/bin/bwrap` is missing, Codex also surfaces a startup warning instead - of printing directly from the sandbox helper. +- If `/usr/bin/bwrap` is present and supports the required argv-rewrite flags, + the helper uses it. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, the + helper falls back to the vendored bubblewrap path. +- If `/usr/bin/bwrap` is missing or too old to support the required flags, + Codex also surfaces a startup warning instead of printing directly from the + sandbox helper. - Legacy Landlock + mount protections remain available as an explicit legacy fallback path. - Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`) diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 37a860e085..7d7e040844 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -4,6 +4,8 @@ use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; use crate::vendored_bwrap::exec_vendored_bwrap; use codex_utils_absolute_path::AbsolutePathBuf; @@ -24,17 +26,41 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { } fn preferred_bwrap_launcher() -> BubblewrapLauncher { - if !Path::new(SYSTEM_BWRAP_PATH).is_file() { + static LAUNCHER: OnceLock = OnceLock::new(); + LAUNCHER + .get_or_init(|| preferred_bwrap_launcher_for_path(Path::new(SYSTEM_BWRAP_PATH))) + .clone() +} + +fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { + if !system_bwrap_supports_argv0(system_bwrap_path) { return BubblewrapLauncher::Vendored; } - let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(SYSTEM_BWRAP_PATH) { + let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, - Err(err) => panic!("failed to normalize system bubblewrap path {SYSTEM_BWRAP_PATH}: {err}"), + Err(err) => panic!( + "failed to normalize system bubblewrap path {}: {err}", + system_bwrap_path.display() + ), }; BubblewrapLauncher::System(system_bwrap_path) } +fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { + // bubblewrap added `--argv0` in v0.9.0: + // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 + // Older distro packages (for example Ubuntu 20.04/22.04) ship builds that + // reject `--argv0`, so prefer the vendored build in that case. + let output = match Command::new(system_bwrap_path).arg("--help").output() { + Ok(output) => output, + Err(_) => return false, + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout.contains("--argv0") || stderr.contains("--argv0") +} + fn exec_system_bwrap( program: &AbsolutePathBuf, argv: Vec, @@ -100,7 +126,57 @@ fn clear_cloexec(fd: libc::c_int) { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::fs; + use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; + use tempfile::TempPath; + + #[test] + fn prefers_system_bwrap_when_help_lists_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo ' --argv0 PROGRAM' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::System(expected) + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_lacks_argv0() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +if [ "$1" = "--help" ]; then + echo 'usage: bwrap [OPTION...] COMMAND' + exit 0 +fi +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + + assert_eq!( + preferred_bwrap_launcher_for_path(fake_bwrap_path), + BubblewrapLauncher::Vendored + ); + } + + #[test] + fn falls_back_to_vendored_when_system_bwrap_is_missing() { + assert_eq!( + preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + BubblewrapLauncher::Vendored + ); + } #[test] fn preserved_files_are_made_inheritable_for_system_exec() { @@ -131,4 +207,13 @@ mod tests { } flags } + + fn write_fake_bwrap(contents: &str) -> TempPath { + // Linux rejects exec-ing a file that is still open for writing. + let path = NamedTempFile::new().expect("temp file").into_temp_path(); + fs::write(&path, contents).expect("write fake bwrap"); + let permissions = fs::Permissions::from_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); + path + } } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 1e4cd06d3a..31860cd585 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1090,10 +1090,13 @@ impl AuthManager { } /// Current cached auth (clone). May be `None` if not logged in or load failed. - /// Refreshes cached ChatGPT tokens if they are stale before returning. + /// For stale managed ChatGPT auth, first performs a guarded reload and then + /// refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { let auth = self.auth_cached()?; - if let Err(err) = self.refresh_if_stale(&auth).await { + if Self::is_stale_for_proactive_refresh(&auth) + && let Err(err) = self.refresh_token().await + { tracing::error!("Failed to refresh token: {}", err); return Some(auth); } @@ -1320,30 +1323,21 @@ impl AuthManager { self.auth_cached().as_ref().map(CodexAuth::auth_mode) } - async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result { + fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, - _ => return Ok(false), + _ => return false, }; let auth_dot_json = match chatgpt_auth.current_auth_json() { Some(auth_dot_json) => auth_dot_json, - None => return Ok(false), - }; - let tokens = match auth_dot_json.tokens { - Some(tokens) => tokens, - None => return Ok(false), + None => return false, }; let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, - None => return Ok(false), + None => return false, }; - if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { - return Ok(false); - } - self.refresh_and_persist_chatgpt_token(chatgpt_auth, tokens.refresh_token) - .await?; - Ok(true) + last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) } async fn refresh_external_auth( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8fe93e6779..5c68d3c3e3 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,6 +9,7 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; use crate::protocol::AskForApproval; @@ -481,6 +482,7 @@ const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("prompts/permissions/approval_policy/on_request.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); +const GUARDIAN_SUBAGENT_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `guardian_subagent`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); @@ -491,6 +493,14 @@ const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_m const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_start.md"); const REALTIME_END_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtime_end.md"); +struct PermissionsPromptConfig<'a> { + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + exec_policy: &'a Policy, + exec_permission_approvals_enabled: bool, + request_permissions_tool_enabled: bool, +} + impl DeveloperInstructions { pub fn new>(text: T) -> Self { Self { text: text.into() } @@ -498,6 +508,7 @@ impl DeveloperInstructions { pub fn from( approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, @@ -541,6 +552,14 @@ impl DeveloperInstructions { ), }; + let text = if approvals_reviewer == ApprovalsReviewer::GuardianSubagent + && approval_policy != AskForApproval::Never + { + format!("{text}\n\n{GUARDIAN_SUBAGENT_APPROVAL_SUFFIX}") + } else { + text + }; + DeveloperInstructions::new(text) } @@ -590,6 +609,7 @@ impl DeveloperInstructions { pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, exec_policy: &Policy, cwd: &Path, exec_permission_approvals_enabled: bool, @@ -614,11 +634,14 @@ impl DeveloperInstructions { DeveloperInstructions::from_permissions_with_network( sandbox_mode, network_access, - approval_policy, - exec_policy, + PermissionsPromptConfig { + approval_policy, + approvals_reviewer, + exec_policy, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + }, writable_roots, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, ) } @@ -639,11 +662,8 @@ impl DeveloperInstructions { fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, - approval_policy: AskForApproval, - exec_policy: &Policy, + config: PermissionsPromptConfig<'_>, writable_roots: Option>, - exec_permission_approvals_enabled: bool, - request_permissions_tool_enabled: bool, ) -> Self { let start_tag = DeveloperInstructions::new(""); let end_tag = DeveloperInstructions::new(""); @@ -653,10 +673,11 @@ impl DeveloperInstructions { network_access, )) .concat(DeveloperInstructions::from( - approval_policy, - exec_policy, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, + config.approval_policy, + config.approvals_reviewer, + config.exec_policy, + config.exec_permission_approvals_enabled, + config.request_permissions_tool_enabled, )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) @@ -1923,11 +1944,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -1954,6 +1978,7 @@ mod tests { let instructions = DeveloperInstructions::from_policy( &policy, AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, &Policy::empty(), &PathBuf::from("/tmp"), false, @@ -1976,11 +2001,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &exec_policy, + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &exec_policy, + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + }, None, - false, - false, ); let text = instructions.into_text(); @@ -1994,11 +2022,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::UnlessTrusted, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::UnlessTrusted, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2011,11 +2042,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnFailure, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2028,11 +2062,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: false, + }, None, - true, - false, ); let text = instructions.into_text(); @@ -2045,11 +2082,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: true, + }, None, - false, - true, ); let text = instructions.into_text(); @@ -2064,11 +2104,14 @@ mod tests { let instructions = DeveloperInstructions::from_permissions_with_network( SandboxMode::WorkspaceWrite, NetworkAccess::Enabled, - AskForApproval::OnRequest, - &Policy::empty(), + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: true, + request_permissions_tool_enabled: true, + }, None, - true, - true, ); let text = instructions.into_text(); @@ -2076,6 +2119,35 @@ mod tests { assert!(text.contains("# request_permissions Tool")); } + #[test] + fn guardian_subagent_approvals_append_guardian_specific_guidance() { + let text = DeveloperInstructions::from( + AskForApproval::OnRequest, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains("materially safer alternative")); + } + + #[test] + fn guardian_subagent_approvals_omit_guardian_specific_guidance_when_approval_is_never() { + let text = DeveloperInstructions::from( + AskForApproval::Never, + ApprovalsReviewer::GuardianSubagent, + &Policy::empty(), + false, + false, + ) + .into_text(); + + assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); + } + fn granular_categories_section(title: &str, categories: &[&str]) -> String { format!("{title}\n{}", categories.join("\n")) } @@ -2118,6 +2190,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2151,6 +2224,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, @@ -2183,6 +2257,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), false, false, @@ -2215,6 +2290,7 @@ mod tests { request_permissions: true, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2230,6 +2306,7 @@ mod tests { request_permissions: false, mcp_elicitations: true, }), + ApprovalsReviewer::User, &Policy::empty(), true, true, @@ -2249,6 +2326,7 @@ mod tests { request_permissions: true, mcp_elicitations: false, }), + ApprovalsReviewer::User, &Policy::empty(), true, false, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..b5c96f4d05 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -38,6 +38,7 @@ use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -254,6 +255,11 @@ pub enum Op { /// 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, @@ -293,6 +299,12 @@ pub enum Op { personality: Option, }, + /// 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 turn context for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. @@ -499,6 +511,53 @@ pub enum Op { ListModels, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +pub struct InterAgentCommunication { + pub author: AgentPath, + pub recipient: AgentPath, + #[serde(default)] + pub other_recipients: Vec, + pub content: String, +} + +impl InterAgentCommunication { + pub fn new( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content, + } + } + + pub fn to_response_input_item(&self) -> ResponseInputItem { + ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: serde_json::to_string(self).unwrap_or_default(), + }], + } + } + + pub fn is_message_content(content: &[ContentItem]) -> bool { + Self::from_message_content(content).is_some() + } + + fn from_message_content(content: &[ContentItem]) -> Option { + match content { + [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { + serde_json::from_str(text).ok() + } + _ => None, + } + } +} + impl Op { pub fn kind(&self) -> &'static str { match self { @@ -510,6 +569,7 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", Self::UserTurn { .. } => "user_turn", + Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", Self::PatchApproval { .. } => "patch_approval", @@ -1342,6 +1402,7 @@ pub enum EventMsg { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { + PreToolUse, SessionStart, UserPromptSubmit, Stop, @@ -1538,6 +1599,15 @@ pub enum AgentStatus { NotFound, } +/// Turn kinds that reject same-turn steering. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// Codex errors that we expose to clients. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -1565,6 +1635,11 @@ pub enum CodexErrorInfo { ResponseTooManyFailedAttempts { http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind, + }, ThreadRollbackFailed, Other, } @@ -1573,7 +1648,7 @@ impl CodexErrorInfo { /// Whether this error should mark the current turn as failed when replaying history. pub fn affects_turn_status(&self) -> bool { match self { - Self::ThreadRollbackFailed => false, + Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false, Self::ContextWindowExceeded | Self::UsageLimitExceeded | Self::ServerOverloaded @@ -4211,6 +4286,17 @@ mod tests { assert!(!event.affects_turn_status()); } + #[test] + fn active_turn_not_steerable_error_does_not_affect_turn_status() { + let event = ErrorEvent { + message: "cannot steer a review turn".into(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }; + assert!(!event.affects_turn_status()); + } + #[test] fn generic_error_affects_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md new file mode 100644 index 0000000000..28d47b7548 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -0,0 +1,148 @@ +--- +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 local plugin, add optional plugin structure, or generate or update repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata. +--- + +# Plugin Creator + +## Quick Start + +1. Run the scaffold script: + +```bash + # Plugin names are normalized to lower-case hyphen-case and must be <= 64 chars. + # The generated folder and plugin.json name are always the same. +# Run from repo root (or replace .agents/... with the absolute path to this SKILL). +# By default creates in /plugins/. +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py +``` + +2. Open `/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders. + +3. Generate or update the repo marketplace entry when the plugin should appear in Codex UI ordering: + +```bash +# marketplace.json always lives at /.agents/plugins/marketplace.json +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace +``` + +4. Generate/adjust optional companion folders as needed: + +```bash +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --path \ + --with-skills --with-hooks --with-scripts --with-assets --with-mcp --with-apps --with-marketplace +``` + +`` is the directory where the plugin folder `` will be created (for example `~/code/plugins`). + +## What this skill creates + +- 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 `/.agents/plugins/marketplace.json` 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. +- `` is normalized using skill-creator naming rules: + - `My Plugin` → `my-plugin` + - `My--Plugin` → `my-plugin` + - underscores, spaces, and punctuation are converted to `-` + - result is lower-case hyphen-delimited with consecutive hyphens collapsed +- Supports optional creation of: + - `skills/` + - `hooks/` + - `scripts/` + - `assets/` + - `.mcp.json` + - `.app.json` + +## Marketplace workflow + +- `marketplace.json` always lives at `/.agents/plugins/marketplace.json`. +- 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. +- Each generated marketplace entry must include all of: + - `policy.installation` + - `policy.authentication` + - `category` +- Default new entries to: + - `policy.installation: "AVAILABLE"` + - `policy.authentication: "ON_INSTALL"` +- Override defaults only when the user explicitly specifies another allowed value. +- Allowed `policy.installation` values: + - `NOT_AVAILABLE` + - `AVAILABLE` + - `INSTALLED_BY_DEFAULT` +- Allowed `policy.authentication` values: + - `ON_INSTALL` + - `ON_USE` +- Treat `policy.products` as an override. Omit it unless the user explicitly requests product gating. +- The generated plugin entry shape is: + +```json +{ + "name": "plugin-name", + "source": { + "source": "local", + "path": "./plugins/plugin-name" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" +} +``` + +- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name. +- If `/.agents/plugins/marketplace.json` 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]", + "interface": { + "displayName": "[TODO: Marketplace Display Name]" + }, + "plugins": [ + { + "name": "plugin-name", + "source": { + "source": "local", + "path": "./plugins/plugin-name" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} +``` + +## Required behavior + +- 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. +- 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. +- Add `policy.products` only when the user explicitly asks for that override. +- Keep marketplace `source.path` relative to repo root as `./plugins/`. + +## Reference to exact spec sample + +For the exact canonical sample JSON for both plugin manifests and marketplace entries, use: + +- `references/plugin-json-spec.md` + +## Validation + +After editing `SKILL.md`, run: + +```bash +python3 /scripts/quick_validate.py .agents/skills/plugin-creator +``` 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 new file mode 100644 index 0000000000..979b49859f --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/agents/openai.yaml @@ -0,0 +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." + icon_small: "./assets/plugin-creator-small.svg" + icon_large: "./assets/plugin-creator.png" diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg new file mode 100644 index 0000000000..c6e4f67c62 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png new file mode 100644 index 0000000000..4f3d6d82fa Binary files /dev/null and b/codex-rs/skills/src/assets/samples/plugin-creator/assets/plugin-creator.png differ 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 new file mode 100644 index 0000000000..3db14d332a --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -0,0 +1,166 @@ +# Plugin JSON sample spec + +```json +{ + "name": "plugin-name", + "version": "1.2.0", + "description": "Brief plugin description", + "author": { + "name": "Author Name", + "email": "author@example.com", + "url": "https://github.com/author" + }, + "homepage": "https://docs.example.com/plugin", + "repository": "https://github.com/author/plugin", + "license": "MIT", + "keywords": ["keyword1", "keyword2"], + "skills": "./skills/", + "hooks": "./hooks.json", + "mcpServers": "./.mcp.json", + "apps": "./.app.json", + "interface": { + "displayName": "Plugin Display Name", + "shortDescription": "Short description for subtitle", + "longDescription": "Long description for details page", + "developerName": "OpenAI", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://openai.com/", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Summarize my inbox and draft replies for me.", + "Find open bugs and turn them into Linear tickets.", + "Review today's meetings and flag scheduling gaps." + ], + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": [ + "./assets/screenshot1.png", + "./assets/screenshot2.png", + "./assets/screenshot3.png" + ] + } +} +``` + +## Field guide + +### Top-level fields + +- `name` (`string`): Plugin identifier (kebab-case, no spaces). Required if `plugin.json` is provided and used as manifest name and component namespace. +- `version` (`string`): Plugin semantic version. +- `description` (`string`): Short purpose summary. +- `author` (`object`): Publisher identity. + - `name` (`string`): Author or team name. + - `email` (`string`): Contact email. + - `url` (`string`): Author/team homepage or profile URL. +- `homepage` (`string`): Documentation URL for plugin usage. +- `repository` (`string`): Source code URL. +- `license` (`string`): License identifier (for example `MIT`, `Apache-2.0`). +- `keywords` (`array` of `string`): Search/discovery tags. +- `skills` (`string`): Relative path to skill directories/files. +- `hooks` (`string`): Hook config path. +- `mcpServers` (`string`): MCP config path. +- `apps` (`string`): App manifest path for plugin integrations. +- `interface` (`object`): Interface/UX metadata block for plugin presentation. + +### `interface` fields + +- `displayName` (`string`): User-facing title shown for the plugin. +- `shortDescription` (`string`): Brief subtitle used in compact views. +- `longDescription` (`string`): Longer description used on details screens. +- `developerName` (`string`): Human-readable publisher name. +- `category` (`string`): Plugin category bucket. +- `capabilities` (`array` of `string`): Capability list from implementation. +- `websiteURL` (`string`): Public website for the plugin. +- `privacyPolicyURL` (`string`): Privacy policy URL. +- `termsOfServiceURL` (`string`): Terms of service URL. +- `defaultPrompt` (`array` of `string`): Starter prompts shown in composer/UX context. + - Include at most 3 strings. Entries after the first 3 are ignored and will not be included. + - Each string is capped at 128 characters. Longer entries are truncated. + - Prefer short starter prompts around 50 characters so they scan well in the UI. +- `brandColor` (`string`): Theme color for the plugin card. +- `composerIcon` (`string`): Path to icon asset. +- `logo` (`string`): Path to logo asset. +- `screenshots` (`array` of `string`): List of screenshot asset paths. + - Screenshot entries must be PNG filenames and stored under `./assets/`. + - Keep file paths relative to plugin root. + +### Path conventions and defaults + +- Path values should be relative and begin with `./`. +- `skills`, `hooks`, and `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. +- Custom path values must follow the plugin root convention and naming/namespacing rules. +- This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates. + +# Marketplace JSON sample spec + +`marketplace.json` depends on where the plugin should live: + +- Repo plugin: `/.agents/plugins/marketplace.json` +- Local plugin: `~/.agents/plugins/marketplace.json` + +```json +{ + "name": "openai-curated", + "interface": { + "displayName": "ChatGPT Official" + }, + "plugins": [ + { + "name": "linear", + "source": { + "source": "local", + "path": "./plugins/linear" + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + "category": "Productivity" + } + ] +} +``` + +## Marketplace field guide + +### Top-level fields + +- `name` (`string`): Marketplace identifier or catalog name. +- `interface` (`object`, optional): Marketplace presentation metadata. +- `plugins` (`array`): Ordered plugin entries. This order determines how Codex renders plugins. + +### `interface` fields + +- `displayName` (`string`, optional): User-facing marketplace title. + +### Plugin entry fields + +- `name` (`string`): Plugin identifier. Match the plugin folder name and `plugin.json` `name`. +- `source` (`object`): Plugin source descriptor. + - `source` (`string`): Use `local` for this repo workflow. + - `path` (`string`): Relative plugin path based on the marketplace root. + - Repo plugin: `./plugins/` + - Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/` +- `policy` (`object`): Marketplace policy block. Always include it. + - `installation` (`string`): Availability policy. + - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` + - Default for new entries: `AVAILABLE` + - `authentication` (`string`): Authentication timing policy. + - Allowed values: `ON_INSTALL`, `ON_USE` + - Default for new entries: `ON_INSTALL` + - `products` (`array` of `string`, optional): Product override for this plugin entry. Omit it unless product gating is explicitly requested. +- `category` (`string`): Display category bucket. Always include it. + +### Marketplace generation rules + +- `displayName` belongs under the top-level `interface` object, not individual plugin entries. +- When creating a new marketplace file from scratch, seed `interface.displayName` alongside top-level `name`. +- Always include `policy.installation`, `policy.authentication`, and `category` on every generated or updated plugin entry. +- Treat `policy.products` as an override and omit it unless explicitly requested. +- Append new entries unless the user explicitly requests reordering. +- Replace an existing entry for the same plugin only when overwrite is intentional. +- Choose marketplace location to match the plugin destination: + - Repo plugin: `/.agents/plugins/marketplace.json` + - Local plugin: `~/.agents/plugins/marketplace.json` 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 new file mode 100755 index 0000000000..5a61093164 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Scaffold a plugin directory and optionally update marketplace.json.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +MAX_PLUGIN_NAME_LENGTH = 64 +DEFAULT_PLUGIN_PARENT = Path.cwd() / "plugins" +DEFAULT_MARKETPLACE_PATH = Path.cwd() / ".agents" / "plugins" / "marketplace.json" +DEFAULT_INSTALL_POLICY = "AVAILABLE" +DEFAULT_AUTH_POLICY = "ON_INSTALL" +DEFAULT_CATEGORY = "Productivity" +DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]" +VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"} +VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"} + + +def normalize_plugin_name(plugin_name: str) -> str: + """Normalize a plugin name to lowercase hyphen-case.""" + normalized = plugin_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def validate_plugin_name(plugin_name: str) -> None: + if not plugin_name: + raise ValueError("Plugin name must include at least one letter or digit.") + if len(plugin_name) > MAX_PLUGIN_NAME_LENGTH: + raise ValueError( + f"Plugin name '{plugin_name}' is too long ({len(plugin_name)} characters). " + f"Maximum is {MAX_PLUGIN_NAME_LENGTH} characters." + ) + + +def build_plugin_json(plugin_name: str) -> dict: + return { + "name": plugin_name, + "version": "[TODO: 1.2.0]", + "description": "[TODO: Brief plugin description]", + "author": { + "name": "[TODO: Author Name]", + "email": "[TODO: author@example.com]", + "url": "[TODO: https://github.com/author]", + }, + "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]", + "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]", + ], + }, + } + + +def build_marketplace_entry( + plugin_name: str, + install_policy: str, + auth_policy: str, + category: str, +) -> dict[str, Any]: + return { + "name": plugin_name, + "source": { + "source": "local", + "path": f"./plugins/{plugin_name}", + }, + "policy": { + "installation": install_policy, + "authentication": auth_policy, + }, + "category": category, + } + + +def load_json(path: Path) -> dict[str, Any]: + with path.open() as handle: + return json.load(handle) + + +def build_default_marketplace() -> dict[str, Any]: + return { + "name": "[TODO: marketplace-name]", + "interface": { + "displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME, + }, + "plugins": [], + } + + +def validate_marketplace_interface(payload: dict[str, Any]) -> None: + interface = payload.get("interface") + if interface is not None and not isinstance(interface, dict): + raise ValueError("marketplace.json field 'interface' must be an object.") + + +def update_marketplace_json( + marketplace_path: Path, + plugin_name: str, + install_policy: str, + auth_policy: str, + category: str, + force: bool, +) -> None: + if marketplace_path.exists(): + payload = load_json(marketplace_path) + else: + payload = build_default_marketplace() + + if not isinstance(payload, dict): + raise ValueError(f"{marketplace_path} must contain a JSON object.") + + validate_marketplace_interface(payload) + + plugins = payload.setdefault("plugins", []) + if not isinstance(plugins, list): + raise ValueError(f"{marketplace_path} field 'plugins' must be an array.") + + new_entry = build_marketplace_entry(plugin_name, install_policy, auth_policy, category) + + for index, entry in enumerate(plugins): + if isinstance(entry, dict) and entry.get("name") == plugin_name: + if not force: + raise FileExistsError( + f"Marketplace entry '{plugin_name}' already exists in {marketplace_path}. " + "Use --force to overwrite that entry." + ) + plugins[index] = new_entry + break + else: + plugins.append(new_entry) + + write_json(marketplace_path, payload, force=True) + + +def write_json(path: Path, data: dict, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"{path} already exists. Use --force to overwrite.") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def create_stub_file(path: Path, payload: dict, force: bool) -> None: + if path.exists() and not force: + return + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + json.dump(payload, handle, indent=2) + handle.write("\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a plugin skeleton with placeholder plugin.json." + ) + parser.add_argument("plugin_name") + parser.add_argument( + "--path", + default=str(DEFAULT_PLUGIN_PARENT), + help="Parent directory for plugin creation (defaults to /plugins)", + ) + parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") + parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") + parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory") + parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory") + parser.add_argument("--with-mcp", action="store_true", help="Create .mcp.json placeholder") + parser.add_argument("--with-apps", action="store_true", help="Create .app.json placeholder") + parser.add_argument( + "--with-marketplace", + action="store_true", + help="Create or update /.agents/plugins/marketplace.json", + ) + parser.add_argument( + "--marketplace-path", + default=str(DEFAULT_MARKETPLACE_PATH), + help="Path to marketplace.json (defaults to /.agents/plugins/marketplace.json)", + ) + parser.add_argument( + "--install-policy", + default=DEFAULT_INSTALL_POLICY, + choices=sorted(VALID_INSTALL_POLICIES), + help="Marketplace policy.installation value", + ) + parser.add_argument( + "--auth-policy", + default=DEFAULT_AUTH_POLICY, + choices=sorted(VALID_AUTH_POLICIES), + help="Marketplace policy.authentication value", + ) + parser.add_argument( + "--category", + default=DEFAULT_CATEGORY, + help="Marketplace category value", + ) + parser.add_argument("--force", action="store_true", help="Overwrite existing files") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + raw_plugin_name = args.plugin_name + plugin_name = normalize_plugin_name(raw_plugin_name) + if plugin_name != raw_plugin_name: + print(f"Note: Normalized plugin name from '{raw_plugin_name}' to '{plugin_name}'.") + validate_plugin_name(plugin_name) + + plugin_root = (Path(args.path).expanduser().resolve() / plugin_name) + 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) + + optional_directories = { + "skills": args.with_skills, + "hooks": args.with_hooks, + "scripts": args.with_scripts, + "assets": args.with_assets, + } + for folder, enabled in optional_directories.items(): + if enabled: + (plugin_root / folder).mkdir(parents=True, exist_ok=True) + + if args.with_mcp: + create_stub_file( + plugin_root / ".mcp.json", + {"mcpServers": {}}, + args.force, + ) + + if args.with_apps: + create_stub_file( + plugin_root / ".app.json", + { + "apps": {}, + }, + args.force, + ) + + if args.with_marketplace: + marketplace_path = Path(args.marketplace_path).expanduser().resolve() + update_marketplace_json( + marketplace_path, + plugin_name, + args.install_policy, + args.auth_policy, + args.category, + args.force, + ) + + print(f"Created plugin scaffold: {plugin_root}") + print(f"plugin manifest: {plugin_json_path}") + if args.with_marketplace: + print(f"marketplace manifest: {marketplace_path}") + + +if __name__ == "__main__": + main() diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21c..5b43840b49 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -45,14 +45,19 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; use codex_core::CodexAuth; +use codex_core::ForkSnapshot; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -355,6 +360,72 @@ async fn request_plugin_detail( response } +async fn request_plugin_install( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + params: PluginInstallParams, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginInstall { request_id, params }) + .await + .wrap_err("plugin/install failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/install"); + } + response +} + +async fn request_plugin_uninstall( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + plugin_id: String, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall"); + } + response +} + fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); @@ -398,8 +469,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -1365,6 +1436,85 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = request_plugin_install( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + ) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = request_plugin_uninstall( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + plugin_id, + ) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + fn clear_ui_header_lines_with_version( &self, width: u16, @@ -2197,7 +2347,7 @@ impl App { 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); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await; tui.set_notification_method(config.tui_notification_method); @@ -2353,7 +2503,7 @@ impl App { ); let forked = thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config.clone(), target_session.path.clone(), /*persist_extended_history*/ false, @@ -2776,7 +2926,7 @@ impl App { match self .server .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, self.config.clone(), path.clone(), /*persist_extended_history*/ false, @@ -2953,6 +3103,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(cwd); } @@ -2962,6 +3121,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -2983,6 +3154,55 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); self.refresh_status_surfaces(); @@ -3400,6 +3620,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -3930,7 +4175,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } @@ -5008,7 +5257,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5090,7 +5339,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5171,7 +5420,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5246,7 +5495,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 71fc7be27a..b5e81edd9f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,15 +10,18 @@ use std::path::PathBuf; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -193,6 +196,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8116cf972b..bc3ff38599 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,9 +3584,41 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -3666,18 +3698,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5383,6 +5427,79 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".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/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); + assert_eq!( + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c9e16b411..eda37fe9c4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -825,8 +825,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1153,7 +1155,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1556,7 +1559,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1583,7 +1590,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1611,7 +1622,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 315e311e02..b9690c28ee 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -176,6 +204,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); @@ -289,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..f3ad37e731 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 12744049fa..77d57c3f48 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8d9e3b61b..ade9235b40 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; @@ -155,6 +156,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -195,14 +198,21 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -528,6 +538,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -720,8 +736,11 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + pending_mcp_output_requests: usize, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -755,6 +774,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -915,9 +936,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -1456,9 +1479,6 @@ impl ChatWidget { cwds: Vec::new(), force_reload: true, }); - if self.connectors_enabled() { - self.prefetch_connectors(); - } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -1803,7 +1823,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1825,7 +1845,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -1897,6 +1917,50 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + if !self.bottom_pane.is_task_running() { + // Will drain rejected_steers_queue in case the steer rejection arrives after task completion + self.maybe_send_next_queued_input(); + } + self.refresh_pending_input_preview(); + true + } + + fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool { + matches!( + codex_error_info, + CodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2184,6 +2248,7 @@ impl ChatWidget { } fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let codex_apps_ready = ev.ready.iter().any(|server| server == "codex_apps"); let mut parts = Vec::new(); if !ev.failed.is_empty() { let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); @@ -2202,6 +2267,11 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); self.maybe_send_next_queued_input(); + if self.connectors_enabled() && codex_apps_ready { + // Populate `$` app mentions from the session's already-started MCP manager + // instead of doing a separate TUI-side connector prefetch. + self.submit_op(Op::ListMcpTools); + } self.request_redraw(); } @@ -2255,7 +2325,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2267,11 +2337,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2317,14 +2388,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2356,13 +2430,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2376,6 +2461,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -3613,8 +3702,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3665,8 +3753,11 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -3680,6 +3771,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3814,8 +3906,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3866,8 +3957,11 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -3886,6 +3980,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4007,8 +4102,7 @@ impl ChatWidget { settings: fallback_default, }; - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4059,8 +4153,11 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4074,6 +4171,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4216,9 +4314,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4442,6 +4540,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { @@ -4468,7 +4569,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -4947,10 +5048,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4965,12 +5063,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -5168,6 +5260,7 @@ impl ChatWidget { items, cwd: self.config.cwd.clone(), approval_policy: self.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), @@ -5375,7 +5468,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5387,8 +5482,11 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info.as_ref().and_then(rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5747,7 +5845,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5766,8 +5864,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -5943,10 +6049,6 @@ impl ChatWidget { self.prefetch_connectors_with_options(force_refetch); } - fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); - } - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { if !self.connectors_enabled() { return; @@ -8368,8 +8470,8 @@ impl ChatWidget { .is_empty() { self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); + } else if self.submit_op(Op::ListMcpTools) { + self.pending_mcp_output_requests = self.pending_mcp_output_requests.saturating_add(1); } } @@ -8617,7 +8719,7 @@ impl ChatWidget { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); return; } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); @@ -8793,9 +8895,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } @@ -8831,13 +8938,77 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); + if self.connectors_enabled() { + let mut connectors_by_id: HashMap = HashMap::new(); + for tool in ev.tools.values() { + let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else { + continue; + }; + let Some(connector_id) = meta + .get("connector_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + else { + continue; + }; + connectors_by_id + .entry(connector_id.to_string()) + .or_insert_with(|| { + let name = meta + .get("connector_name") + .or_else(|| meta.get("connector_display_name")) + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(connector_id) + .to_string(); + let description = meta + .get("connector_description") + .or_else(|| meta.get("connectorDescription")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + connectors::AppInfo { + id: connector_id.to_string(), + name, + description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + }); + } + + let mut app_connectors = connectors_by_id.into_values().collect::>(); + app_connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + let app_connectors = connectors::with_app_enabled_state(app_connectors, &self.config); + self.bottom_pane + .set_connectors_snapshot(Some(ConnectorsSnapshot { + connectors: app_connectors, + })); + } + + if self.pending_mcp_output_requests > 0 { + self.pending_mcp_output_requests -= 1; + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { @@ -9224,13 +9395,6 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - #[cfg(not(target_os = "linux"))] - if self.realtime_conversation.is_live() - && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) - { - self.realtime_conversation.meter_placeholder_id = None; - self.request_realtime_conversation_close(/*info_message*/ None); - } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); @@ -9440,6 +9604,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e4eaecd51..a9250672c5 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallResponse; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { return; } + let auth_flow_active = self.plugin_install_auth_flow.is_some(); + match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); self.plugins_cache = PluginsCacheState::Ready(response.clone()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - self.plugins_fetch_state.cache_cwd = None; - self.plugins_cache = PluginsCacheState::Failed(err.clone()); - let _ = self.bottom_pane.replace_selection_view_if_active( - PLUGINS_SELECTION_VIEW_ID, - self.plugins_error_popup_params(&err), - ); + if !auth_flow_active { + self.plugins_fetch_state.cache_cwd = None; + self.plugins_cache = PluginsCacheState::Failed(err.clone()); + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_error_popup_params(&err), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_install_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + + pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_uninstall_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -162,6 +185,291 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_install_loaded( + &mut self, + cwd: PathBuf, + _marketplace_path: AbsolutePathBuf, + _plugin_name: String, + plugin_display_name: String, + result: Result, + ) -> bool { + if self.config.cwd != cwd { + return true; + } + + match result { + Ok(response) => { + self.plugin_install_apps_needing_auth = response.apps_needing_auth; + self.plugin_install_auth_flow = None; + if self.plugin_install_apps_needing_auth.is_empty() { + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some("No additional app authentication is required.".to_string()), + ); + true + } else { + let app_names = self + .plugin_install_apps_needing_auth + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", "); + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some(format!( + "{} app(s) still need authentication: {app_names}", + self.plugin_install_apps_needing_auth.len() + )), + ); + self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { + plugin_display_name, + next_app_index: 0, + }); + self.open_plugin_install_auth_popup(); + false + } + } + Err(err) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + true + } + } + } + + pub(crate) fn on_plugin_uninstall_loaded( + &mut self, + cwd: PathBuf, + plugin_display_name: String, + result: Result, + ) { + if self.config.cwd != cwd { + return; + } + + match result { + Ok(_response) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + self.add_info_message( + format!("Uninstalled {plugin_display_name} plugin."), + Some("Bundled apps remain installed.".to_string()), + ); + } + Err(err) => { + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + } + } + } + + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { + let should_finish = { + let Some(flow) = self.plugin_install_auth_flow.as_mut() else { + return; + }; + flow.next_app_index += 1; + flow.next_app_index >= self.plugin_install_apps_needing_auth.len() + }; + + if should_finish { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + } + + self.open_plugin_install_auth_popup(); + } + + pub(crate) fn abandon_plugin_install_auth_flow(&mut self) { + self.finish_plugin_install_auth_flow(/*abandoned*/ true); + } + + fn open_plugin_install_auth_popup(&mut self) { + let Some(params) = self.plugin_install_auth_popup_params() else { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + }; + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + && let Some(params) = self.plugin_install_auth_popup_params() + { + self.bottom_pane.show_selection_view(params); + } + } + + fn plugin_install_auth_popup_params(&self) -> Option { + let flow = self.plugin_install_auth_flow.as_ref()?; + let app = self + .plugin_install_apps_needing_auth + .get(flow.next_app_index)?; + let total = self.plugin_install_apps_needing_auth.len(); + let current = flow.next_app_index + 1; + let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str()); + let status_label = if is_installed { + "Already installed in this session." + } else { + "Not installed yet." + }; + let description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{} plugin installed.", flow.plugin_display_name).bold(), + )); + header.push(Line::from( + format!("App setup {current}/{total}: {}", app.name).dim(), + )); + header.push(Line::from(status_label.dim())); + + let mut items = vec![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + if let Some(install_url) = app.install_url.clone() { + let install_label = if is_installed { + "Manage on ChatGPT" + } else { + "Install on ChatGPT" + }; + items.push(SelectionItem { + name: install_label.to_string(), + description: Some( + "Open the same ChatGPT app management link used by /apps.".to_string(), + ), + selected_description: Some("Open the app page in your browser.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: install_url.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "ChatGPT link unavailable".to_string(), + description: Some("This app did not provide an install/manage URL.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + if is_installed { + items.push(SelectionItem { + name: "Continue".to_string(), + description: Some("This app is already installed.".to_string()), + selected_description: Some("Advance to the next app.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: false, + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "I've installed it".to_string(), + description: Some( + "Trust your confirmation and continue to the next app.".to_string(), + ), + selected_description: Some( + "Continue without waiting for refresh to complete.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: true, + }); + })], + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skip remaining app setup".to_string(), + description: Some("Stop this follow-up flow for this plugin.".to_string()), + selected_description: Some("Abandon remaining required app setup.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAbandon); + })], + ..Default::default() + }); + + Some(SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugins_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }) + } + + fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool { + self.connectors_for_mentions().is_some_and(|connectors| { + connectors + .iter() + .any(|connector| connector.id == app_id && connector.is_accessible) + }) + } + + fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) { + let Some(flow) = self.plugin_install_auth_flow.take() else { + return; + }; + self.plugin_install_apps_needing_auth.clear(); + if abandoned { + self.add_info_message( + format!( + "Skipped remaining app setup for {} plugin.", + flow.plugin_display_name + ), + Some("The plugin may not be usable until required apps are installed.".to_string()), + ); + } else { + self.add_info_message( + format!( + "Completed app setup flow for {} plugin.", + flow.plugin_display_name + ), + Some("You can now continue managing plugins from /plugins.".to_string()), + ); + } + + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + if let Some(plugins_response) = plugins_response { + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_popup_params(&plugins_response), + ); + } + } + fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, @@ -212,6 +520,52 @@ impl ChatWidget { } } + fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + let plugin_id = plugin.summary.id.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + let install_cwd = self.config.cwd.clone(); + let marketplace_path = plugin.marketplace_path.clone(); + let plugin_name = plugin.summary.name.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + marketplace_path: marketplace_path.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 2e4ab70e70..df51a72317 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -106,6 +106,23 @@ pub(super) struct PendingSteerCompareKey { } impl ChatWidget { + pub(super) fn stop_realtime_conversation_from_ui(&mut self) { + self.request_realtime_conversation_close(/*info_message*/ None); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool { + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.stop_realtime_conversation_from_ui(); + return true; + } + + false + } + pub(super) fn rendered_user_message_event_from_parts( message: String, text_elements: Vec, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..de6cfaddaf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 0000000000..d2461ec72f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 0000000000..54132d3bf5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 0000000000..eddb869168 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 0000000000..b46cdb825b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap similarity index 59% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap index 27474ef6d7..11fa8ab8e3 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -1,10 +1,9 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- -• Running SessionStart hook: warming the shell +• Running PreToolUse hook: warming the shell -SessionStart hook (completed) +PreToolUse hook (completed) warning: Heads up from the hook hook context: Remember the startup checklist. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 79c08c42ed..e514585661 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 91% rename from codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap index 27474ef6d7..30dfa78e5b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_events_render_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__session_start_hook_events_render_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 8586 expression: combined --- • Running SessionStart hook: warming the shell diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a614d1361d..2300421966 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -18,6 +18,18 @@ use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::SkillSummary; use codex_core::CodexAuth; use codex_core::config::ApprovalsReviewer; use codex_core::config::Config; @@ -35,6 +47,7 @@ use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::ModelsManager; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -89,6 +102,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -120,6 +134,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -1534,6 +1550,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1893,8 +2034,11 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), @@ -1912,6 +2056,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3704,9 +3849,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3721,6 +3868,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3755,10 +3934,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3790,37 +3969,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } @@ -4060,6 +4304,97 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_codex_event(Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -4873,13 +5208,13 @@ async fn realtime_error_closes_without_followup_closed_info() { #[cfg(not(target_os = "linux"))] #[tokio::test] -async fn removing_active_realtime_placeholder_closes_realtime_conversation() { +async fn deleted_realtime_meter_uses_shared_stop_path() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); - chat.remove_transcription_placeholder(&placeholder_id); + assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id)); next_realtime_close_op(&mut op_rx); assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); @@ -6747,6 +7082,513 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + 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: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + enabled: true, + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + fn selected_permissions_popup_line(popup: &str) -> &str { popup .lines() @@ -11210,7 +12052,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -11218,15 +12086,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -11240,15 +12108,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -11271,7 +12139,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. @@ -11500,9 +12368,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11514,19 +12390,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -11534,6 +12419,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11544,9 +12437,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 171df69270..bb36d83949 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -48,21 +48,28 @@ use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -435,8 +442,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } -fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) { - let Some(message) = codex_core::config::missing_system_bwrap_warning() else { +fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) { + let Some(message) = codex_core::config::system_bwrap_warning() else { return; }; @@ -464,8 +471,6 @@ enum ThreadBufferedEvent { Notification(ServerNotification), Request(ServerRequest), HistoryEntryResponse(GetHistoryEntryResponseEvent), - LegacyWarning(String), - LegacyRollback { num_turns: u32 }, } #[derive(Debug)] @@ -474,7 +479,6 @@ struct ThreadEventStore { turns: Vec, buffer: VecDeque, pending_interactive_replay: PendingInteractiveReplayState, - pending_local_legacy_rollbacks: VecDeque, active_turn_id: Option, input_state: Option, capacity: usize, @@ -483,10 +487,7 @@ struct ThreadEventStore { impl ThreadEventStore { fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool { - matches!( - event, - ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::LegacyWarning(_) - ) + matches!(event, ThreadBufferedEvent::Request(_)) } fn new(capacity: usize) -> Self { @@ -495,7 +496,6 @@ impl ThreadEventStore { turns: Vec::new(), buffer: VecDeque::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), - pending_local_legacy_rollbacks: VecDeque::new(), active_turn_id: None, input_state: None, capacity, @@ -521,7 +521,6 @@ impl ThreadEventStore { } fn set_turns(&mut self, turns: Vec) { - self.pending_local_legacy_rollbacks.clear(); self.active_turn_id = turns .iter() .rev() @@ -578,37 +577,6 @@ impl ThreadEventStore { self.active_turn_id = None; } - fn note_local_thread_rollback(&mut self, num_turns: u32) { - self.pending_local_legacy_rollbacks.push_back(num_turns); - while self.pending_local_legacy_rollbacks.len() > self.capacity { - self.pending_local_legacy_rollbacks.pop_front(); - } - } - - fn consume_pending_local_legacy_rollback(&mut self, num_turns: u32) -> bool { - match self.pending_local_legacy_rollbacks.front() { - Some(pending_num_turns) if *pending_num_turns == num_turns => { - self.pending_local_legacy_rollbacks.pop_front(); - true - } - _ => false, - } - } - - fn apply_legacy_thread_rollback(&mut self, num_turns: u32) { - let num_turns = usize::try_from(num_turns).unwrap_or(usize::MAX); - if num_turns >= self.turns.len() { - self.turns.clear(); - } else { - self.turns - .truncate(self.turns.len().saturating_sub(num_turns)); - } - self.buffer.clear(); - self.pending_interactive_replay = PendingInteractiveReplayState::default(); - self.pending_local_legacy_rollbacks.clear(); - self.active_turn_id = None; - } - fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { session: self.session.clone(), @@ -623,9 +591,7 @@ impl ThreadEventStore { .pending_interactive_replay .should_replay_snapshot_request(request), ThreadBufferedEvent::Notification(_) - | ThreadBufferedEvent::HistoryEntryResponse(_) - | ThreadBufferedEvent::LegacyWarning(_) - | ThreadBufferedEvent::LegacyRollback { .. } => true, + | ThreadBufferedEvent::HistoryEntryResponse(_) => true, }) .cloned() .collect(), @@ -1008,6 +974,18 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { + let TypedRequestError::Server { source, .. } = error else { + return None; + }; + let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; + matches!( + turn_error.codex_error_info, + Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) + ) + .then_some(turn_error) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -1857,6 +1835,57 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = fetch_plugin_uninstall(request_handle, plugin_id) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + /// Process the completed MCP inventory fetch: clear the loading spinner, then /// render either the full tool/resource listing or an error into chat history. /// @@ -1981,6 +2010,7 @@ impl App { items, cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -1991,9 +2021,21 @@ impl App { personality, } => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { - app_server + match app_server .turn_steer(thread_id, turn_id, items.to_vec()) - .await?; + .await + { + Ok(_) => {} + Err(error) => { + if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { + if !self.chat_widget.enqueue_rejected_steer() { + self.chat_widget.add_error_message(turn_error.message); + } + } else { + return Err(error.into()); + } + } + } } else { app_server .turn_start( @@ -2001,7 +2043,8 @@ impl App { items.to_vec(), cwd.clone(), approval_policy, - self.chat_widget.config_ref().approvals_reviewer, + approvals_reviewer + .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), model.to_string(), effort, @@ -2283,50 +2326,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_warning( - &mut self, - thread_id: ThreadId, - message: String, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - guard - .buffer - .push_back(ThreadBufferedEvent::LegacyWarning(message.clone())); - if guard.buffer.len() > guard.capacity - && let Some(removed) = guard.buffer.pop_front() - && let ThreadBufferedEvent::Request(request) = &removed - { - guard - .pending_interactive_replay - .note_evicted_server_request(request); - } - guard.active - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyWarning(message)) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - async fn enqueue_thread_history_entry_response( &mut self, thread_id: ThreadId, @@ -2371,64 +2370,6 @@ impl App { Ok(()) } - async fn enqueue_thread_legacy_rollback( - &mut self, - thread_id: ThreadId, - num_turns: u32, - ) -> Result<()> { - let (sender, store) = { - let channel = self.ensure_thread_channel(thread_id); - (channel.sender.clone(), Arc::clone(&channel.store)) - }; - - let should_send = { - let mut guard = store.lock().await; - if guard.consume_pending_local_legacy_rollback(num_turns) { - false - } else { - guard.apply_legacy_thread_rollback(num_turns); - guard.active - } - }; - - if should_send { - match sender.try_send(ThreadBufferedEvent::LegacyRollback { num_turns }) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - tokio::spawn(async move { - if let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); - } - }); - } - Err(TrySendError::Closed(_)) => { - tracing::warn!("thread {thread_id} event channel closed"); - } - } - } - Ok(()) - } - - async fn enqueue_primary_thread_legacy_warning(&mut self, message: String) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self.enqueue_thread_legacy_warning(thread_id, message).await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyWarning(message)); - Ok(()) - } - - async fn enqueue_primary_thread_legacy_rollback(&mut self, num_turns: u32) -> Result<()> { - if let Some(thread_id) = self.primary_thread_id { - return self - .enqueue_thread_legacy_rollback(thread_id, num_turns) - .await; - } - self.pending_primary_events - .push_back(ThreadBufferedEvent::LegacyRollback { num_turns }); - Ok(()) - } - async fn enqueue_primary_thread_session( &mut self, session: ThreadSessionState, @@ -2466,14 +2407,6 @@ impl App { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } - ThreadBufferedEvent::LegacyWarning(message) => { - self.enqueue_thread_legacy_warning(thread_id, message) - .await?; - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await?; - } } } self.chat_widget @@ -2945,7 +2878,7 @@ impl App { 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); - emit_missing_system_bwrap_warning(&app_event_tx); + emit_system_bwrap_warning(&app_event_tx); tui.set_notification_method(config.tui_notification_method); let harness_overrides = @@ -3679,6 +3612,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } @@ -3688,6 +3630,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } @@ -3697,6 +3651,62 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install( + app_server, + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + ); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + app_server, + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::FetchMcpInventory => { self.fetch_mcp_inventory(app_server); } @@ -4136,6 +4146,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -4666,7 +4701,11 @@ impl App { AppEvent::UpdateRecordingMeter { id, text } => { // Update in place to preserve the element id for subsequent frames. let updated = self.chat_widget.update_transcription_in_place(&id, &text); - if updated { + if updated + || self + .chat_widget + .stop_realtime_conversation_for_deleted_meter(&id) + { tui.frame_requester().schedule_frame(); } } @@ -4769,7 +4808,6 @@ impl App { if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; store.apply_thread_rollback(response); - store.note_local_thread_rollback(num_turns); } if self.active_thread_id == Some(thread_id) && let Some(mut rx) = self.active_thread_rx.take() @@ -4814,13 +4852,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event); } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } if needs_refresh { self.refresh_status_line(); @@ -4838,13 +4869,6 @@ impl App { ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event) } - ThreadBufferedEvent::LegacyWarning(message) => { - self.chat_widget.add_warning_message(message); - } - ThreadBufferedEvent::LegacyRollback { num_turns } => { - self.handle_backtrack_rollback_succeeded(num_turns); - self.chat_widget.handle_thread_rolled_back(); - } } } @@ -5272,6 +5296,42 @@ async fn fetch_plugin_detail( .wrap_err("plugin/read failed in app-server TUI") } +async fn fetch_plugin_install( + request_handle: AppServerRequestHandle, + marketplace_path: AbsolutePathBuf, + plugin_name: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginInstall { + request_id, + params: PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/install failed in app-server TUI") +} + +async fn fetch_plugin_uninstall( + request_handle: AppServerRequestHandle, + plugin_id: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in app-server TUI") +} + /// Convert flat `McpServerStatus` responses into the per-server maps used by the /// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus /// per-server resource/template/auth maps). Test-only because the app-server TUI @@ -5335,10 +5395,13 @@ mod tests { use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; + use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -5351,6 +5414,7 @@ mod tests { use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; @@ -5900,7 +5964,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -5935,33 +5999,6 @@ mod tests { } } - #[tokio::test] - async fn replay_thread_snapshot_replays_legacy_warning_history() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - - app.replay_thread_snapshot( - ThreadEventSnapshot { - session: None, - turns: Vec::new(), - events: vec![ThreadBufferedEvent::LegacyWarning( - "legacy warning message".to_string(), - )], - input_state: None, - }, - false, - ); - - let mut saw_warning = false; - while let Ok(event) = app_event_rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let transcript = lines_to_single_string(&cell.transcript_lines(80)); - saw_warning |= transcript.contains("legacy warning message"); - } - } - - assert!(saw_warning, "expected replayed legacy warning history cell"); - } - #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5977,7 +6014,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6026,7 +6063,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6073,7 +6110,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -6143,7 +6180,7 @@ mod tests { app.chat_widget .apply_external_edit("queued follow-up".to_string()); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let input_state = app .chat_widget .capture_thread_input_state() @@ -7308,87 +7345,6 @@ guardian_approval = true Ok(()) } - #[tokio::test] - async fn legacy_warning_eviction_clears_pending_interactive_replay_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let channel = ThreadEventChannel::new(1); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_warning(thread_id, "legacy warning".to_string()) - .await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.events.len(), 1); - assert!(matches!( - snapshot.events.first(), - Some(ThreadBufferedEvent::LegacyWarning(message)) if message == "legacy warning" - )); - - Ok(()) - } - - #[tokio::test] - async fn legacy_thread_rollback_trims_inactive_thread_snapshot_state() -> Result<()> { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - let channel = ThreadEventChannel::new_with_session(4, session, turns); - { - let mut store = channel.store.lock().await; - store.push_request(exec_approval_request( - thread_id, - "turn-approval", - "call-approval", - None, - )); - assert_eq!(store.has_pending_thread_approvals(), true); - } - app.thread_event_channels.insert(thread_id, channel); - - app.enqueue_thread_legacy_rollback(thread_id, 1).await?; - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread store should exist") - .store - .lock() - .await; - assert_eq!( - store.turns, - vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())] - ); - assert_eq!(store.has_pending_thread_approvals(), false); - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns, store.turns); - assert!(snapshot.events.is_empty()); - - Ok(()) - } - #[tokio::test] async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { let mut app = make_test_app().await; @@ -8108,16 +8064,6 @@ guardian_approval = true assert_eq!(store.has_pending_thread_approvals(), false); } - #[test] - fn thread_event_store_consumes_matching_local_legacy_rollback_once() { - let mut store = ThreadEventStore::new(8); - store.note_local_thread_rollback(2); - - assert!(store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(2)); - assert!(!store.consume_pending_local_legacy_rollback(1)); - } - fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { let mut seen = Vec::new(); while let Ok(op) = op_rx.try_recv() { @@ -8300,6 +8246,30 @@ guardian_approval = true ); } + #[test] + fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { + let turn_error = AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: AppServerNonSteerableTurnKind::Review, + }), + additional_details: None, + }; + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: turn_error.message.clone(), + data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")), + }, + }; + + assert_eq!( + active_turn_not_steerable_turn_error(&error), + Some(turn_error) + ); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); @@ -9076,8 +9046,13 @@ guardian_approval = true let (tx, rx) = mpsc::channel(8); app.active_thread_id = Some(thread_id); app.active_thread_rx = Some(rx); - tx.send(ThreadBufferedEvent::LegacyWarning( - "stale warning".to_string(), + tx.send(ThreadBufferedEvent::Notification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "stale warning".to_string(), + details: None, + path: None, + range: None, + }), )) .await .expect("event should queue"); @@ -9115,62 +9090,6 @@ guardian_approval = true assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); } - #[tokio::test] - async fn local_rollback_response_suppresses_matching_legacy_rollback() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let session = test_thread_session(thread_id, PathBuf::from("/tmp/project")); - let initial_turns = vec![ - test_turn("turn-1", TurnStatus::Completed, Vec::new()), - test_turn("turn-2", TurnStatus::Completed, Vec::new()), - ]; - app.thread_event_channels.insert( - thread_id, - ThreadEventChannel::new_with_session(8, session, initial_turns), - ); - - app.handle_thread_rollback_response( - thread_id, - 1, - &ThreadRollbackResponse { - thread: Thread { - id: thread_id.to_string(), - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: codex_app_server_protocol::ThreadStatus::Idle, - path: None, - cwd: PathBuf::from("/tmp/project"), - cli_version: "0.0.0".to_string(), - source: SessionSource::Cli.into(), - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())], - }, - }, - ) - .await; - - app.enqueue_thread_legacy_rollback(thread_id, 1) - .await - .expect("legacy rollback should not fail"); - - let store = app - .thread_event_channels - .get(&thread_id) - .expect("thread channel") - .store - .lock() - .await; - let snapshot = store.snapshot(); - assert_eq!(snapshot.turns.len(), 1); - assert!(snapshot.events.is_empty()); - } - #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index c144e36dbe..952a9bf7b8 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -16,13 +16,9 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::app_server_rate_limit_snapshot_to_core; use crate::app_server_session::status_account_display_from_auth_mode; -use crate::local_chatgpt_auth::load_local_chatgpt_auth; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; #[cfg(test)] @@ -106,16 +102,9 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; #[cfg(test)] use codex_protocol::protocol::TurnStartedEvent; -use serde_json::Value; #[cfg(test)] use std::time::Duration; -#[derive(Debug, PartialEq, Eq)] -enum LegacyThreadNotification { - Warning(String), - Rollback { num_turns: u32 }, -} - impl App { pub(super) async fn handle_app_server_event( &mut self, @@ -133,48 +122,10 @@ impl App { self.handle_server_notification_event(app_server_client, notification) .await; } - AppServerEvent::LegacyNotification(notification) => { - if let Some((thread_id, legacy_notification)) = - legacy_thread_notification(notification) - { - let result = match legacy_notification { - LegacyThreadNotification::Warning(message) => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_warning(message).await - } else { - self.enqueue_thread_legacy_warning(thread_id, message).await - } - } - LegacyThreadNotification::Rollback { num_turns } => { - if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_legacy_rollback(num_turns).await - } else { - self.enqueue_thread_legacy_rollback(thread_id, num_turns) - .await - } - } - }; - if let Err(err) = result { - tracing::warn!("failed to enqueue app-server legacy notification: {err}"); - } - } else { - tracing::debug!("ignoring legacy app-server notification in tui_app_server"); - } + AppServerEvent::LegacyNotification(_) => { + tracing::debug!("ignoring legacy app-server notification in tui_app_server"); } AppServerEvent::ServerRequest(request) => { - if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request { - self.handle_chatgpt_auth_tokens_refresh_request( - app_server_client, - request_id, - params, - ) - .await; - return; - } self.handle_server_request_event(app_server_client, request) .await; } @@ -293,71 +244,6 @@ impl App { tracing::warn!("failed to enqueue app-server request: {err}"); } } - - async fn handle_chatgpt_auth_tokens_refresh_request( - &mut self, - app_server_client: &AppServerSession, - request_id: RequestId, - params: ChatgptAuthTokensRefreshParams, - ) { - let config = self.config.clone(); - let result = tokio::task::spawn_blocking(move || { - resolve_chatgpt_auth_tokens_refresh_response( - &config.codex_home, - config.cli_auth_credentials_store_mode, - config.forced_chatgpt_workspace_id.as_deref(), - ¶ms, - ) - }) - .await; - - match result { - Ok(Ok(response)) => { - let response = serde_json::to_value(response).map_err(|err| { - format!("failed to serialize chatgpt auth refresh response: {err}") - }); - match response { - Ok(response) => { - if let Err(err) = app_server_client - .resolve_server_request(request_id, response) - .await - { - tracing::warn!("failed to resolve chatgpt auth refresh request: {err}"); - } - } - Err(err) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - Ok(Err(err)) => { - self.chat_widget.add_error_message(err.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, err) - .await - { - tracing::warn!("{reject_err}"); - } - } - Err(err) => { - let message = format!("chatgpt auth refresh task failed: {err}"); - self.chat_widget.add_error_message(message.clone()); - if let Err(reject_err) = self - .reject_app_server_request(app_server_client, request_id, message) - .await - { - tracing::warn!("{reject_err}"); - } - } - } - } - async fn reject_app_server_request( &self, app_server_client: &AppServerSession, @@ -519,28 +405,6 @@ fn server_notification_thread_target( } } -fn resolve_chatgpt_auth_tokens_refresh_response( - codex_home: &std::path::Path, - auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, - params: &ChatgptAuthTokensRefreshParams, -) -> Result { - let auth = load_local_chatgpt_auth( - codex_home, - auth_credentials_store_mode, - forced_chatgpt_workspace_id, - )?; - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != auth.chatgpt_account_id - { - return Err(format!( - "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - auth.chatgpt_account_id - )); - } - Ok(auth.to_refresh_response()) -} - #[cfg(test)] /// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s /// suitable for replaying into the TUI event store. @@ -567,48 +431,6 @@ pub(super) fn thread_snapshot_events( .collect() } -fn legacy_thread_notification( - notification: JSONRPCNotification, -) -> Option<(ThreadId, LegacyThreadNotification)> { - let method = notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method); - - let Value::Object(mut params) = notification.params? else { - return None; - }; - let thread_id = params - .remove("conversationId") - .and_then(|value| serde_json::from_value::(value).ok()) - .and_then(|value| ThreadId::from_string(&value).ok())?; - let msg = params.get("msg").and_then(Value::as_object)?; - - match method { - "warning" => { - let message = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("message")) - .and_then(|(kind, message)| (kind == "warning").then_some(message)) - .and_then(Value::as_str) - .map(ToOwned::to_owned)?; - Some((thread_id, LegacyThreadNotification::Warning(message))) - } - "thread_rolled_back" => { - let num_turns = msg - .get("type") - .and_then(Value::as_str) - .zip(msg.get("num_turns")) - .and_then(|(kind, num_turns)| (kind == "thread_rolled_back").then_some(num_turns)) - .and_then(Value::as_u64) - .and_then(|num_turns| u32::try_from(num_turns).ok())?; - Some((thread_id, LegacyThreadNotification::Rollback { num_turns })) - } - _ => None, - } -} - #[cfg(test)] fn server_notification_thread_events( notification: ServerNotification, @@ -1153,113 +975,6 @@ fn split_command_string(command: &str) -> Vec { } } -#[cfg(test)] -mod refresh_tests { - use super::*; - - use base64::Engine; - use chrono::Utc; - use codex_app_server_protocol::AuthMode; - use codex_core::auth::AuthCredentialsStoreMode; - use codex_core::auth::AuthDotJson; - use codex_core::auth::save_auth; - use codex_core::token_data::TokenData; - use pretty_assertions::assert_eq; - use serde::Serialize; - use serde_json::json; - use tempfile::TempDir; - - fn fake_jwt(account_id: &str, plan_type: &str) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_account_id": account_id, - "chatgpt_plan_type": plan_type, - }, - }); - let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); - let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); - let signature_b64 = encode(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - fn write_chatgpt_auth(codex_home: &std::path::Path) { - let id_token = fake_jwt("workspace-1", "business"); - let access_token = fake_jwt("workspace-1", "business"); - save_auth( - codex_home, - &AuthDotJson { - auth_mode: Some(AuthMode::Chatgpt), - openai_api_key: None, - tokens: Some(TokenData { - id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) - .expect("id token should parse"), - access_token, - refresh_token: "refresh-token".to_string(), - account_id: Some("workspace-1".to_string()), - }), - last_refresh: Some(Utc::now()), - }, - AuthCredentialsStoreMode::File, - ) - .expect("chatgpt auth should save"); - } - - #[test] - fn refresh_request_uses_local_chatgpt_auth() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let response = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-1".to_string()), - }, - ) - .expect("refresh response should resolve"); - - assert_eq!(response.chatgpt_account_id, "workspace-1"); - assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); - assert!(!response.access_token.is_empty()); - } - - #[test] - fn refresh_request_rejects_account_mismatch() { - let codex_home = TempDir::new().expect("tempdir"); - write_chatgpt_auth(codex_home.path()); - - let err = resolve_chatgpt_auth_tokens_refresh_response( - codex_home.path(), - AuthCredentialsStoreMode::File, - Some("workspace-1"), - &ChatgptAuthTokensRefreshParams { - reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, - previous_account_id: Some("workspace-2".to_string()), - }, - ) - .expect_err("mismatched account should fail"); - - assert_eq!( - err, - "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" - ); - } -} - #[cfg(test)] fn app_server_web_search_action_to_core( action: codex_app_server_protocol::WebSearchAction, @@ -1289,9 +1004,7 @@ fn app_server_codex_error_info_to_core( #[cfg(test)] mod tests { - use super::LegacyThreadNotification; use super::command_execution_started_event; - use super::legacy_thread_notification; use super::server_notification_thread_events; use super::thread_snapshot_events; use super::turn_snapshot_events; @@ -1303,7 +1016,6 @@ mod tests { use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; - use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Thread; @@ -1324,57 +1036,8 @@ mod tests { use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use pretty_assertions::assert_eq; - use serde_json::json; use std::path::PathBuf; - #[test] - fn legacy_warning_notification_extracts_thread_id_and_message() { - let thread_id = ThreadId::new(); - let warning = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/warning".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "warning", - "message": "legacy warning message", - }, - })), - }); - - assert_eq!( - warning, - Some(( - thread_id, - LegacyThreadNotification::Warning("legacy warning message".to_string()) - )) - ); - } - - #[test] - fn legacy_thread_rollback_notification_extracts_thread_id_and_turn_count() { - let thread_id = ThreadId::new(); - let rollback = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/thread_rolled_back".to_string(), - params: Some(json!({ - "conversationId": thread_id.to_string(), - "id": "event-1", - "msg": { - "type": "thread_rolled_back", - "num_turns": 2, - }, - })), - }); - - assert_eq!( - rollback, - Some(( - thread_id, - LegacyThreadNotification::Rollback { num_turns: 2 } - )) - ); - } - #[test] fn bridges_completed_agent_messages_from_server_notifications() { let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..e01a250274 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -42,6 +42,7 @@ pub(crate) enum AppCommandView<'a> { items: &'a [UserInput], cwd: &'a PathBuf, approval_policy: AskForApproval, + approvals_reviewer: &'a Option, sandbox_policy: &'a SandboxPolicy, model: &'a str, effort: Option, @@ -159,6 +160,7 @@ impl AppCommand { items, cwd, approval_policy, + approvals_reviewer: None, sandbox_policy, model, effort, @@ -303,6 +305,7 @@ impl AppCommand { items, cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -315,6 +318,7 @@ impl AppCommand { items, cwd, approval_policy: *approval_policy, + approvals_reviewer, sandbox_policy, model, effort: *effort, diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index a763410dd0..f5284fd610 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -11,9 +11,11 @@ use std::path::PathBuf; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -21,6 +23,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -195,6 +198,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + /// Fetch MCP inventory via app-server RPCs and render it into history. FetchMcpInventory, diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index c8a24acff4..0da85bc617 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,6 +1,7 @@ use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; @@ -429,7 +430,7 @@ impl AppServerSession { thread_id: ThreadId, turn_id: String, items: Vec, - ) -> Result { + ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { @@ -441,7 +442,6 @@ impl AppServerSession { }, }) .await - .wrap_err("turn/steer failed in app-server TUI") } pub(crate) async fn thread_set_name( diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 0e1f3e08d6..98452410bb 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,9 +3599,41 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); + let plugin_display_names: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .map(|plugin| plugin.display_name.to_ascii_lowercase()) + .collect() + }); if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -3681,18 +3713,30 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } + let plugin_backed_connector = connector + .plugin_display_names + .iter() + .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); + if plugin_backed_connector { + continue; + } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); + let category_tag = if connector.plugin_display_names.is_empty() { + "[App]".to_string() + } else { + "[Plugin]".to_string() + }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some("[App]".to_string()), + category_tag: Some(category_tag), sort_rank: 1, }); } @@ -5398,6 +5442,79 @@ mod tests { assert_eq!(mention.path, Some("plugin://sample@test".to_string())); } + #[test] + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_skill_mentions(Some(vec![SkillMetadata { + name: "google-calendar:availability".to_string(), + description: "Find availability and plan event changes".to_string(), + short_description: None, + interface: Some(codex_core::skills::model::SkillInterface { + display_name: Some("Google Calendar".to_string()), + short_description: None, + icon_small: None, + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), + scope: codex_protocol::protocol::SkillScope::Repo, + }])); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: Some( + "Connect Google Calendar for scheduling, availability, and event management." + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".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/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); + assert_eq!( + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs index c1c5296648..fe903a0b37 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs @@ -70,7 +70,7 @@ impl FileSearchPopup { } self.display_query = query.to_string(); - self.matches = matches; + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); self.waiting = false; let len = self.matches.len(); self.state.clamp_selection(len); @@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_file_search::MatchType; + use pretty_assertions::assert_eq; + + fn file_match(index: usize) -> FileMatch { + FileMatch { + score: index as u32, + path: PathBuf::from(format!("src/file_{index:02}.rs")), + match_type: MatchType::File, + root: PathBuf::from("/tmp/repo"), + indices: None, + } + } + + #[test] + fn set_matches_keeps_only_the_first_page_of_results() { + let mut popup = FileSearchPopup::new(); + popup.set_query("file"); + popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect()); + + assert_eq!( + popup.matches, + (0..MAX_POPUP_ROWS).map(file_match).collect::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index 2531f8586b..dd90bc11bd 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -821,8 +821,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1143,7 +1145,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1546,7 +1549,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1573,7 +1580,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1601,7 +1612,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 315e311e02..b9690c28ee 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -176,6 +204,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); @@ -289,6 +332,9 @@ mod tests { queue .pending_steers .push("Check the last command output.".to_string()); + queue + .rejected_steers + .push("Rejected steer that will be retried.".to_string()); queue .queued_messages .push("Queued follow-up question".to_string()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs index 841ce23b8b..061d3f3b82 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs @@ -180,6 +180,7 @@ impl SkillPopup { }) }); + out.truncate(MAX_POPUP_ROWS); out } } @@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> { " to close".into(), ]) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn mention_item(index: usize) -> MentionItem { + MentionItem { + display_name: format!("Mention {index:02}"), + description: Some(format!("Description {index:02}")), + insert_text: format!("$mention-{index:02}"), + search_terms: vec![format!("mention-{index:02}")], + path: Some(format!("skill://mention-{index:02}")), + category_tag: Some("[Skill]".to_string()), + sort_rank: 1, + } + } + + #[test] + fn filtered_mentions_are_capped_to_max_popup_rows() { + let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect()); + + let filtered_names: Vec = popup + .filtered_items() + .into_iter() + .map(|idx| popup.mentions[idx].display_name.clone()) + .collect(); + + assert_eq!( + filtered_names, + (0..MAX_POPUP_ROWS) + .map(|idx| format!("Mention {idx:02}")) + .collect::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000..d70db56644 --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 0f0d1eabd3..16da87fb91 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 0d53ae3f7a..9363e3e356 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -59,6 +59,7 @@ use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; @@ -199,6 +200,8 @@ use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -238,14 +241,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet." /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -557,6 +567,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -763,6 +779,8 @@ pub(crate) struct ChatWidget { connectors_force_refetch_pending: bool, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -798,6 +816,8 @@ pub(crate) struct ChatWidget { suppress_initial_user_message_submit: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -949,9 +969,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -2163,7 +2185,7 @@ impl ChatWidget { let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); - if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -2185,7 +2207,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - if !self.queued_user_messages.is_empty() { + if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { @@ -2257,6 +2279,57 @@ impl ChatWidget { }); } + fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + fn pop_next_queued_user_message(&mut self) -> Option { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + self.refresh_pending_input_preview(); + true + } + + #[cfg(test)] + fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { + matches!( + codex_error_info, + CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2499,7 +2572,11 @@ impl ChatWidget { message: String, codex_error_info: Option, ) { - if let Some(info) = codex_error_info + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) { @@ -2639,7 +2716,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2651,11 +2728,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2701,14 +2779,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2740,13 +2821,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2760,6 +2852,9 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4170,8 +4265,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4224,6 +4318,8 @@ impl ChatWidget { connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4236,6 +4332,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4373,9 +4470,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4599,6 +4696,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.compact(); } SlashCommand::Review => { @@ -4625,7 +4725,7 @@ impl ChatWidget { return; } if self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); } else { self.start_realtime_conversation(); } @@ -5091,10 +5191,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -5109,12 +5206,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -6398,7 +6489,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -6410,8 +6503,13 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = core_rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info + .as_ref() + .and_then(core_rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -6776,7 +6874,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -6795,8 +6893,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -9548,10 +9654,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_warning_message(&mut self, message: String) { - self.on_warning(message); - } - fn add_app_server_stub_message(&mut self, feature: &str) { warn!(feature, "stubbed unsupported app-server TUI feature"); self.add_error_message(format!("{feature}: {APP_SERVER_TUI_STUB_MESSAGE}")); @@ -9850,7 +9952,7 @@ impl ChatWidget { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - self.request_realtime_conversation_close(/*info_message*/ None); + self.stop_realtime_conversation_from_ui(); return; } let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); @@ -10031,9 +10133,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } @@ -10458,13 +10565,6 @@ impl ChatWidget { } pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - #[cfg(not(target_os = "linux"))] - if self.realtime_conversation.is_live() - && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) - { - self.realtime_conversation.meter_placeholder_id = None; - self.request_realtime_conversation_close(/*info_message*/ None); - } self.bottom_pane.remove_transcription_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); @@ -10674,6 +10774,7 @@ fn extract_first_bold(s: &str) -> Option { fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 5e4eaecd51..a9250672c5 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallResponse; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { return; } + let auth_flow_active = self.plugin_install_auth_flow.is_some(); + match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); self.plugins_cache = PluginsCacheState::Ready(response.clone()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - self.plugins_fetch_state.cache_cwd = None; - self.plugins_cache = PluginsCacheState::Failed(err.clone()); - let _ = self.bottom_pane.replace_selection_view_if_active( - PLUGINS_SELECTION_VIEW_ID, - self.plugins_error_popup_params(&err), - ); + if !auth_flow_active { + self.plugins_fetch_state.cache_cwd = None; + self.plugins_cache = PluginsCacheState::Failed(err.clone()); + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_error_popup_params(&err), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_install_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + + pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) { + let params = self.plugin_uninstall_loading_popup_params(plugin_display_name); + let _ = self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -162,6 +185,291 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_install_loaded( + &mut self, + cwd: PathBuf, + _marketplace_path: AbsolutePathBuf, + _plugin_name: String, + plugin_display_name: String, + result: Result, + ) -> bool { + if self.config.cwd != cwd { + return true; + } + + match result { + Ok(response) => { + self.plugin_install_apps_needing_auth = response.apps_needing_auth; + self.plugin_install_auth_flow = None; + if self.plugin_install_apps_needing_auth.is_empty() { + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some("No additional app authentication is required.".to_string()), + ); + true + } else { + let app_names = self + .plugin_install_apps_needing_auth + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", "); + self.add_info_message( + format!("Installed {plugin_display_name} plugin."), + Some(format!( + "{} app(s) still need authentication: {app_names}", + self.plugin_install_apps_needing_auth.len() + )), + ); + self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { + plugin_display_name, + next_app_index: 0, + }); + self.open_plugin_install_auth_popup(); + false + } + } + Err(err) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + true + } + } + } + + pub(crate) fn on_plugin_uninstall_loaded( + &mut self, + cwd: PathBuf, + plugin_display_name: String, + result: Result, + ) { + if self.config.cwd != cwd { + return; + } + + match result { + Ok(_response) => { + self.plugin_install_apps_needing_auth.clear(); + self.plugin_install_auth_flow = None; + self.add_info_message( + format!("Uninstalled {plugin_display_name} plugin."), + Some("Bundled apps remain installed.".to_string()), + ); + } + Err(err) => { + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), + ); + } + } + } + + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { + let should_finish = { + let Some(flow) = self.plugin_install_auth_flow.as_mut() else { + return; + }; + flow.next_app_index += 1; + flow.next_app_index >= self.plugin_install_apps_needing_auth.len() + }; + + if should_finish { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + } + + self.open_plugin_install_auth_popup(); + } + + pub(crate) fn abandon_plugin_install_auth_flow(&mut self) { + self.finish_plugin_install_auth_flow(/*abandoned*/ true); + } + + fn open_plugin_install_auth_popup(&mut self) { + let Some(params) = self.plugin_install_auth_popup_params() else { + self.finish_plugin_install_auth_flow(/*abandoned*/ false); + return; + }; + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + && let Some(params) = self.plugin_install_auth_popup_params() + { + self.bottom_pane.show_selection_view(params); + } + } + + fn plugin_install_auth_popup_params(&self) -> Option { + let flow = self.plugin_install_auth_flow.as_ref()?; + let app = self + .plugin_install_apps_needing_auth + .get(flow.next_app_index)?; + let total = self.plugin_install_apps_needing_auth.len(); + let current = flow.next_app_index + 1; + let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str()); + let status_label = if is_installed { + "Already installed in this session." + } else { + "Not installed yet." + }; + let description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{} plugin installed.", flow.plugin_display_name).bold(), + )); + header.push(Line::from( + format!("App setup {current}/{total}: {}", app.name).dim(), + )); + header.push(Line::from(status_label.dim())); + + let mut items = vec![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + if let Some(install_url) = app.install_url.clone() { + let install_label = if is_installed { + "Manage on ChatGPT" + } else { + "Install on ChatGPT" + }; + items.push(SelectionItem { + name: install_label.to_string(), + description: Some( + "Open the same ChatGPT app management link used by /apps.".to_string(), + ), + selected_description: Some("Open the app page in your browser.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: install_url.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "ChatGPT link unavailable".to_string(), + description: Some("This app did not provide an install/manage URL.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + if is_installed { + items.push(SelectionItem { + name: "Continue".to_string(), + description: Some("This app is already installed.".to_string()), + selected_description: Some("Advance to the next app.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: false, + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "I've installed it".to_string(), + description: Some( + "Trust your confirmation and continue to the next app.".to_string(), + ), + selected_description: Some( + "Continue without waiting for refresh to complete.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAdvance { + refresh_connectors: true, + }); + })], + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skip remaining app setup".to_string(), + description: Some("Stop this follow-up flow for this plugin.".to_string()), + selected_description: Some("Abandon remaining required app setup.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::PluginInstallAuthAbandon); + })], + ..Default::default() + }); + + Some(SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugins_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }) + } + + fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool { + self.connectors_for_mentions().is_some_and(|connectors| { + connectors + .iter() + .any(|connector| connector.id == app_id && connector.is_accessible) + }) + } + + fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) { + let Some(flow) = self.plugin_install_auth_flow.take() else { + return; + }; + self.plugin_install_apps_needing_auth.clear(); + if abandoned { + self.add_info_message( + format!( + "Skipped remaining app setup for {} plugin.", + flow.plugin_display_name + ), + Some("The plugin may not be usable until required apps are installed.".to_string()), + ); + } else { + self.add_info_message( + format!( + "Completed app setup flow for {} plugin.", + flow.plugin_display_name + ), + Some("You can now continue managing plugins from /plugins.".to_string()), + ); + } + + let plugins_response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(response) => Some(response), + _ => None, + }; + if let Some(plugins_response) = plugins_response { + let _ = self.bottom_pane.replace_selection_view_if_active( + PLUGINS_SELECTION_VIEW_ID, + self.plugins_popup_params(&plugins_response), + ); + } + } + fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, @@ -212,6 +520,52 @@ impl ChatWidget { } } + fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + let plugin_id = plugin.summary.id.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + let install_cwd = self.config.cwd.clone(); + let marketplace_path = plugin.marketplace_path.clone(); + let plugin_name = plugin.summary.name.clone(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + marketplace_path: marketplace_path.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), diff --git a/codex-rs/tui_app_server/src/chatwidget/realtime.rs b/codex-rs/tui_app_server/src/chatwidget/realtime.rs index 0d5363daad..6860a7ca05 100644 --- a/codex-rs/tui_app_server/src/chatwidget/realtime.rs +++ b/codex-rs/tui_app_server/src/chatwidget/realtime.rs @@ -202,6 +202,23 @@ impl ChatWidget { vec![("/realtime".to_string(), "stop live voice".to_string())] } + pub(super) fn stop_realtime_conversation_from_ui(&mut self) { + self.request_realtime_conversation_close(/*info_message*/ None); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool { + if self.realtime_conversation.is_live() + && self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id) + { + self.realtime_conversation.meter_placeholder_id = None; + self.stop_realtime_conversation_from_ui(); + return true; + } + + false + } + pub(super) fn start_realtime_conversation(&mut self) { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..65f7845183 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 0000000000..fe88135b86 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 0000000000..a5f073bc2c --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 0000000000..741c813fb1 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 0000000000..b12a33e638 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap new file mode 100644 index 0000000000..f9a87c5682 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__pre_tool_use_hook_events_render_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: combined +--- +• Running PreToolUse hook: warming the shell + +PreToolUse hook (completed) + warning: Heads up from the hook + hook context: Remember the startup checklist. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap index 3985a1dc2c..ad7a7ed279 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap similarity index 100% rename from codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__hook_events_render_snapshot.snap rename to codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__session_start_hook_events_render_snapshot.snap diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da09..611bf13adc 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool; @@ -32,9 +33,20 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::MarketplaceInterface; use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem as AppServerThreadItem; use codex_app_server_protocol::Turn as AppServerTurn; @@ -58,6 +70,7 @@ use codex_core::config_loader::ConfigRequirements; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -112,6 +125,7 @@ use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; @@ -143,6 +157,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -1558,6 +1574,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + chat.queued_user_messages + .push_back(UserMessage::from("queued later")); + + chat.submit_user_message(UserMessage::from("review follow-up one")); + chat.submit_user_message(UserMessage::from("review follow-up two")); + + assert_eq!(chat.pending_steers.len(), 2); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected second running-turn steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected-1".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + chat.handle_codex_event(Event { + id: "steer-rejected-2".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "review follow-up one", + "review follow-up two", + "queued later" + ] + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "review-exit".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "review follow-up one\nreview follow-up two".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "turn-complete-2".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-2".to_string(), + last_agent_message: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued later".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft submit after rejected steers, got {other:?}"), + } +} + #[tokio::test] async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1914,6 +2055,8 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), @@ -1932,6 +2075,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), @@ -3714,9 +3858,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: true, agent_turn_running: true, })); @@ -3731,6 +3877,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let mut pending_steers = VecDeque::new(); + pending_steers.push_back(UserMessage::from("pending steer")); + let mut rejected_steers_queue = VecDeque::new(); + rejected_steers_queue.push_back(UserMessage::from("already rejected")); + let mut queued_user_messages = VecDeque::new(); + queued_user_messages.push_back(UserMessage::from("queued draft")); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + pending_steers, + rejected_steers_queue, + queued_user_messages, + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + task_running: false, + agent_turn_running: false, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["already rejected", "queued draft"] + ); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "pending steer" + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3765,10 +3943,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3800,37 +3978,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } @@ -4064,6 +4307,107 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn submit_user_message_queues_while_compaction_turn_is_running() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + None, + ); + + chat.submit_user_message(UserMessage::from("queued while compacting")); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn compact steer submit, got {other:?}"), + } + + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued while compacting"] + ); + + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::Completed, + error: None, + }, + }), + None, + ); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while compacting".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"), + } +} + +#[tokio::test] +async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Compact); + + assert!(chat.bottom_pane.is_task_running()); + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Compact)) => {} + other => panic!("expected compact op to be submitted, got {other:?}"), + } + + chat.bottom_pane.set_composer_text( + "queued before compact turn start".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued before compact turn start" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -5489,13 +5833,13 @@ async fn realtime_error_closes_without_followup_closed_info() { #[cfg(not(target_os = "linux"))] #[tokio::test] -async fn removing_active_realtime_placeholder_closes_realtime_conversation() { +async fn deleted_realtime_meter_uses_shared_stop_path() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); - chat.remove_transcription_placeholder(&placeholder_id); + assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id)); next_realtime_close_op(&mut op_rx); assert_eq!(chat.realtime_conversation.meter_placeholder_id, None); @@ -7335,6 +7679,513 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + 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: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + enabled: true, + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + #[tokio::test] async fn apps_popup_stays_loading_until_final_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -11614,7 +12465,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { } #[tokio::test] -async fn hook_events_render_snapshot() { +async fn pre_tool_use_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::PreToolUse, + "pre-tool-use:0:/tmp/hooks.json", + "warming the shell", + "pre_tool_use_hook_events_render_snapshot", + ) + .await; +} + +#[tokio::test] +async fn session_start_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_protocol::protocol::HookEventName::SessionStart, + "session-start:0:/tmp/hooks.json", + "warming the shell", + "session_start_hook_events_render_snapshot", + ) + .await; +} + +async fn assert_hook_events_snapshot( + event_name: codex_protocol::protocol::HookEventName, + run_id: &str, + status_message: &str, + snapshot_name: &str, +) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { @@ -11622,15 +12499,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: None, duration_ms: None, @@ -11644,15 +12521,15 @@ async fn hook_events_render_snapshot() { msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { turn_id: None, run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, + id: run_id.to_string(), + event_name, handler_type: codex_protocol::protocol::HookHandlerType::Command, execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, + scope: codex_protocol::protocol::HookScope::Turn, source_path: PathBuf::from("/tmp/hooks.json"), display_order: 0, status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), + status_message: Some(status_message.to_string()), started_at: 1, completed_at: Some(11), duration_ms: Some(10), @@ -11675,7 +12552,7 @@ async fn hook_events_render_snapshot() { .iter() .map(|lines| lines_to_single_string(lines)) .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(snapshot_name, combined); } // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. @@ -11904,9 +12781,17 @@ async fn chatwidget_tall() { } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { +async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11918,19 +12803,28 @@ async fn enter_queues_user_messages_while_review_is_running() { let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), + "Steer submitted while /review was running.".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.pending_steers.front().unwrap().user_message.text, + "Steer submitted while /review was running." ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Steer submitted while /review was running.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected running-turn steer submit, got {other:?}"), + } assert!(drain_insert_history(&mut rx).is_empty()); } @@ -11938,6 +12832,14 @@ async fn enter_queues_user_messages_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); chat.handle_codex_event(Event { id: "review-1".into(), @@ -11948,9 +12850,57 @@ async fn review_queues_user_messages_snapshot() { }); let _ = drain_insert_history(&mut rx); - chat.queue_user_message(UserMessage::from( - "Queued while /review is running.".to_string(), + chat.submit_user_message(UserMessage::from( + "Steer submitted while /review was running.".to_string(), )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + }), + }); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn compact_queues_user_messages_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + chat.submit_user_message(UserMessage::from( + "Steer submitted while /compact was running.".to_string(), + )); + chat.handle_codex_event(Event { + id: "steer-rejected".into(), + msg: EventMsg::Error(ErrorEvent { + message: "cannot steer a compact turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }), + }), + }); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs index 6fbed6cc79..33d6e508a9 100644 --- a/codex-rs/tui_app_server/src/local_chatgpt_auth.rs +++ b/codex-rs/tui_app_server/src/local_chatgpt_auth.rs @@ -1,7 +1,6 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::load_auth_dot_json; @@ -12,16 +11,6 @@ pub(crate) struct LocalChatgptAuth { pub(crate) chatgpt_plan_type: Option, } -impl LocalChatgptAuth { - pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse { - ChatgptAuthTokensRefreshResponse { - access_token: self.access_token.clone(), - chatgpt_account_id: self.chatgpt_account_id.clone(), - chatgpt_plan_type: self.chatgpt_plan_type.clone(), - } - } -} - pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode,