Merge b7a8c07ece into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-23 12:07:59 -07:00
committed by GitHub
116 changed files with 7153 additions and 3957 deletions
+3 -1
View File
@@ -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
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>
+1 -1
View File
@@ -1328,7 +1328,7 @@
"system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}",
"system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}",
"tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}",
"tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}",
"tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}",
"tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}",
"temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}",
"temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}",
+27 -12
View File
@@ -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]]
@@ -1800,6 +1800,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"
@@ -1857,6 +1871,7 @@ dependencies = [
"codex-arg0",
"codex-artifacts",
"codex-async-utils",
"codex-code-mode",
"codex-config",
"codex-connectors",
"codex-exec-server",
@@ -3806,7 +3821,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4051,7 +4066,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]]
@@ -5496,7 +5511,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6259,7 +6274,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]]
@@ -6903,7 +6918,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]]
@@ -8325,7 +8340,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9739,9 +9754,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -9758,7 +9773,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11204,7 +11219,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]]
+3 -1
View File
@@ -13,6 +13,7 @@ members = [
"feedback",
"features",
"codex-backend-openapi-models",
"code-mode",
"cloud-requirements",
"cloud-tasks",
"cloud-tasks-client",
@@ -91,6 +92,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" }
@@ -374,7 +376,7 @@ ignored = [
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
"codex-v8-poc"
"codex-v8-poc",
]
[profile.release]
@@ -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"
}
]
},
@@ -1623,6 +1645,13 @@
],
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
"compact"
],
"type": "string"
},
"PatchApplyStatus": {
"enum": [
"inProgress",
@@ -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"
}
]
},
@@ -9115,6 +9137,13 @@
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
"compact"
],
"type": "string"
},
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -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"
}
]
},
@@ -5863,6 +5885,13 @@
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
"compact"
],
"type": "string"
},
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -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": {
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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";
@@ -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";
@@ -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";
@@ -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<u16>,
},
/// 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<CoreCodexErrorInfo> 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<CoreNonSteerableTurnKind> 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,
)]
@@ -7832,6 +7862,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 {
+8 -4
View File
@@ -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 `{}`.
@@ -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`
@@ -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;
@@ -6131,24 +6133,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;
}
@@ -25,6 +25,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
@@ -678,6 +679,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
@@ -784,6 +786,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
@@ -850,6 +853,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
.mount(&server)
.await;
@@ -888,6 +892,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
.expect(1)
.mount(&server)
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "code-mode",
crate_name = "codex_code_mode",
)
+25
View File
@@ -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 }
+555
View File
@@ -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<JsonValue>,
pub output_schema: Option<JsonValue>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct CodeModeExecPragma {
#[serde(default)]
yield_time_ms: Option<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ParsedExecSource {
pub code: String,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<usize>,
}
pub fn parse_exec_source(input: &str) -> Result<ParsedExecSource, String> {
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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<String, JsonValue>,
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, JsonValue>) -> 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::<Vec<_>>();
if !item_types.is_empty() {
return format!("[{}]", item_types.join(", "));
}
}
"unknown[]".to_string()
}
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
let required = map
.get("required")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let properties = map
.get("properties")
.and_then(JsonValue::as_object)
.cloned()
.unwrap_or_default();
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
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::<Vec<_>>();
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`)"));
}
}
+30
View File
@@ -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";
+24
View File
@@ -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<ImageDetail>,
},
}
+209
View File
@@ -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<v8::Value>,
) {
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::<RuntimeState>() 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<v8::Value>,
) {
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::<RuntimeState>() {
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<v8::Value>,
) {
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::<RuntimeState>() {
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<v8::Value>,
) {
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::<RuntimeState>() {
state.stored_values.insert(key, serialized);
}
}
pub(super) fn load_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments,
mut retval: v8::ReturnValue<v8::Value>,
) {
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::<RuntimeState>()
.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<v8::Value>,
) {
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::<RuntimeState>() {
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<v8::Value>,
) {
if let Some(state) = scope.get_slot::<RuntimeState>() {
let _ = state.event_tx.send(RuntimeEvent::YieldRequested);
}
}
pub(super) fn exit_callback(
scope: &mut v8::PinScope<'_, '_>,
_args: v8::FunctionCallbackArguments,
_retval: v8::ReturnValue<v8::Value>,
) {
if let Some(state) = scope.get_slot_mut::<RuntimeState>() {
state.exit_requested = true;
}
if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) {
scope.throw_exception(error.into());
}
}
+138
View File
@@ -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<v8::Local<'s, v8::Object>, String> {
let tools = v8::Object::new(scope);
let enabled_tools = scope
.get_slot::<RuntimeState>()
.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<v8::Local<'s, v8::Value>, String> {
let enabled_tools = scope
.get_slot::<RuntimeState>()
.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<v8::Local<'s, v8::Function>, String>
where
F: v8::MapFnTo<v8::FunctionCallback>,
{
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<v8::Local<'s, v8::Function>, 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}`"))
}
}
+349
View File
@@ -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<ToolDefinition>,
pub source: String,
pub stored_values: HashMap<String, JsonValue>,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<usize>,
}
#[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<FunctionCallOutputContentItem>,
},
Terminated {
cell_id: String,
content_items: Vec<FunctionCallOutputContentItem>,
},
Result {
cell_id: String,
content_items: Vec<FunctionCallOutputContentItem>,
stored_values: HashMap<String, JsonValue>,
error_text: Option<String>,
},
}
#[derive(Debug)]
pub(crate) enum TurnMessage {
ToolCall {
cell_id: String,
id: String,
name: String,
input: Option<JsonValue>,
},
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<JsonValue>,
},
Notify {
call_id: String,
text: String,
},
Result {
stored_values: HashMap<String, JsonValue>,
error_text: Option<String>,
},
}
pub(crate) fn spawn_runtime(
request: ExecuteRequest,
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
) -> Result<(std_mpsc::Sender<RuntimeCommand>, 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::<Vec<_>>();
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<EnabledToolMetadata>,
source: String,
stored_values: HashMap<String, JsonValue>,
}
pub(super) struct RuntimeState {
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
pending_tool_calls: HashMap<String, v8::Global<v8::PromiseResolver>>,
stored_values: HashMap<String, JsonValue>,
enabled_tools: Vec<EnabledToolMetadata>,
next_tool_call_id: u64,
tool_call_id: String,
exit_requested: bool,
}
pub(super) enum CompletionState {
Pending,
Completed {
stored_values: HashMap<String, JsonValue>,
error_text: Option<String>,
},
}
fn initialize_v8() {
static PLATFORM: OnceLock<v8::SharedRef<v8::Platform>> = 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<RuntimeEvent>,
command_rx: std_mpsc::Receiver<RuntimeCommand>,
isolate_handle_tx: std_mpsc::SyncSender<v8::IsolateHandle>,
) {
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<RuntimeEvent>,
error_text: Option<String>,
) {
let stored_values = scope
.get_slot::<RuntimeState>()
.map(|state| state.stored_values.clone())
.unwrap_or_default();
send_result(event_tx, stored_values, error_text);
}
fn send_result(
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
stored_values: HashMap<String, JsonValue>,
error_text: Option<String>,
) {
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()
);
}
}
@@ -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<Option<v8::Global<v8::Promise>>, 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::<v8::Promise>::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::<RuntimeState>()
.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<JsonValue, String>,
) -> Result<(), String> {
let resolver = {
let state = scope
.get_slot_mut::<RuntimeState>()
.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<v8::Promise>>,
) -> CompletionState {
let stored_values = scope
.get_slot::<RuntimeState>()
.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<v8::ScriptOrigin<'s>, 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::Local<'s, v8::Module>> {
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<v8::Local<'s, v8::Promise>> {
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<v8::Local<'s, v8::Module>> {
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
}
+163
View File
@@ -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<String, String> {
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<FunctionCallOutputContentItem, ()> {
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
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::<v8::Object>::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<Option<JsonValue>, 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<v8::Local<'s, v8::Value>> {
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::<v8::Object>::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());
}
}
+673
View File
@@ -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<JsonValue>,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String>;
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>;
}
#[derive(Clone)]
struct SessionHandle {
control_tx: mpsc::UnboundedSender<SessionControlCommand>,
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
}
struct Inner {
stored_values: Mutex<HashMap<String, JsonValue>>,
sessions: Mutex<HashMap<String, SessionHandle>>,
turn_message_tx: mpsc::UnboundedSender<TurnMessage>,
turn_message_rx: Arc<Mutex<mpsc::UnboundedReceiver<TurnMessage>>>,
next_cell_id: AtomicU64,
}
pub struct CodeModeService {
inner: Arc<Inner>,
}
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<String, JsonValue> {
self.inner.stored_values.lock().await.clone()
}
pub async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
*self.inner.stored_values.lock().await = values;
}
pub async fn execute(&self, request: ExecuteRequest) -> Result<RuntimeResponse, String> {
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<RuntimeResponse, String> {
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<dyn CodeModeTurnHost>) -> 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<oneshot::Sender<()>>,
}
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<RuntimeResponse>,
},
Terminate {
response_tx: oneshot::Sender<RuntimeResponse>,
},
}
struct PendingResult {
content_items: Vec<FunctionCallOutputContentItem>,
stored_values: HashMap<String, JsonValue>,
error_text: Option<String>,
}
struct SessionControlContext {
cell_id: String,
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
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<oneshot::Sender<RuntimeResponse>>,
pending_result: &mut Option<PendingResult>,
) -> 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<Inner>,
context: SessionControlContext,
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
mut control_rx: mpsc::UnboundedReceiver<SessionControlCommand>,
initial_response_tx: oneshot::Sender<RuntimeResponse>,
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<PendingResult> = None;
let mut response_tx = Some(initial_response_tx);
let mut termination_requested = false;
let mut runtime_closed = false;
let mut yield_timer: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
loop {
tokio::select! {
maybe_event = async {
if runtime_closed {
std::future::pending::<Option<RuntimeEvent>>().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<Inner> {
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);
}
}
+1
View File
@@ -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 }
+67 -19
View File
@@ -1,6 +1,8 @@
use crate::agent::AgentStatus;
use crate::agent::guards::AgentMetadata;
use crate::agent::guards::Guards;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
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;
@@ -80,14 +82,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<ThreadManagerState>,
state: Arc<Guards>,
state: Arc<AgentRegistry>,
}
impl AgentControl {
@@ -454,20 +456,53 @@ impl AgentControl {
items: Vec<UserInput>,
) -> CodexResult<String> {
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<String> {
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state.append_message(agent_id, message).await,
)
.await
}
pub(crate) async fn deliver_inter_agent_instruction(
&self,
agent_id: ThreadId,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let state = self.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state
.deliver_inter_agent_instruction(agent_id, instruction, delivery)
.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<ThreadManagerState>,
result: CodexResult<String>,
) -> CodexResult<String> {
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<String> {
@@ -686,7 +734,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,
+55
View File
@@ -382,6 +382,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;
@@ -0,0 +1,74 @@
use codex_protocol::AgentPath;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InterAgentDelivery {
CurrentTurn,
NextTurn,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct InterAgentInstruction {
author: AgentPath,
recipient: AgentPath,
other_recipients: Vec<AgentPath>,
content: String,
}
impl InterAgentInstruction {
pub(crate) fn new(
author: AgentPath,
recipient: AgentPath,
other_recipients: Vec<AgentPath>,
content: String,
) -> Self {
Self {
author,
recipient,
other_recipients,
content,
}
}
pub(crate) fn to_response_item(&self) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: self.as_text(),
}],
end_turn: None,
phase: None,
}
}
pub(crate) fn is_message_content(content: &[ContentItem]) -> bool {
content.iter().any(|content_item| match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
Self::is_instruction_text(text)
}
_ => false,
})
}
fn as_text(&self) -> String {
let other_recipients = self
.other_recipients
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
format!(
"author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}",
self.author, self.recipient, self.content
)
}
fn is_instruction_text(text: &str) -> bool {
text.starts_with("author: ")
&& text.contains("\nrecipient: ")
&& text.contains("\nother_recipients: [")
&& text.contains("]\nContent: ")
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
pub(crate) mod agent_resolver;
pub(crate) mod control;
mod guards;
pub(crate) mod inter_agent_instruction;
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;
@@ -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<ActiveAgents>,
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<Self>,
max_threads: Option<usize>,
@@ -263,7 +263,7 @@ impl Guards {
}
pub(crate) struct SpawnReservation {
state: Arc<Guards>,
state: Arc<AgentRegistry>,
active: bool,
reserved_agent_nickname: Option<String>,
reserved_agent_path: Option<AgentPath>,
@@ -311,5 +311,5 @@ impl Drop for SpawnReservation {
}
#[cfg(test)]
#[path = "guards_tests.rs"]
#[path = "registry_tests.rs"]
mod tests;
@@ -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
);
}
+128 -53
View File
@@ -186,9 +186,41 @@ mod rollout_reconstruction_tests;
pub enum SteerInputError {
NoActiveTurn(Vec<UserInput>),
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<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
@@ -776,6 +810,7 @@ pub(crate) struct TurnSkillsContext {
pub(crate) outcome: Arc<SkillLoadOutcome>,
pub(crate) implicit_invocation_seen_skills: Arc<Mutex<HashSet<String>>>,
}
impl TurnSkillsContext {
pub(crate) fn new(outcome: Arc<SkillLoadOutcome>) -> 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<ResponseInputItem>,
@@ -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<ResponseInputItem>) {
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<ResponseInputItem> {
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() {
@@ -4525,26 +4595,35 @@ 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(&current_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(&current_context)
.await;
sess.spawn_task(
Arc::clone(&current_context),
items,
crate::tasks::RegularTask::new(),
)
.await;
sess.spawn_task(
Arc::clone(&current_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;
}
}
}
@@ -5383,7 +5462,7 @@ pub(crate) async fn run_turn(
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String> {
if input.is_empty() {
if input.is_empty() && !sess.has_pending_input().await {
return None;
}
@@ -5510,20 +5589,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 +5604,33 @@ pub(crate) async fn run_turn(
})
.collect::<Vec<_>>();
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<String> = 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 +5641,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 +5662,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<String> = 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 +5750,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(),
@@ -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()
@@ -33,6 +33,18 @@ fn assistant_message(text: &str) -> ResponseItem {
}
}
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
#[tokio::test]
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
{
@@ -430,6 +442,96 @@ 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(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: 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;
+200
View File
@@ -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,
@@ -3346,6 +3482,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 +4510,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 +4633,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;
+88 -13
View File
@@ -1,4 +1,6 @@
use crate::agent::AgentStatus;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::Codex;
use crate::codex::SteerInputError;
use crate::config::ConstraintResult;
@@ -122,29 +124,90 @@ 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<ResponseItem> = items_without_active_turn
.into_iter()
.map(ResponseItem::from)
.collect();
/// 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.
pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult<String> {
let submission_id = uuid::Uuid::new_v4().to_string();
let pending_item = pending_message_input_item(&message)?;
if let Err(items) = self
.codex
.session
.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(crate) async fn deliver_inter_agent_instruction(
&self,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let message = instruction.to_response_item();
match delivery {
InterAgentDelivery::CurrentTurn => self.append_message(message).await,
InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await,
}
}
/// Queue a prebuilt message so the next turn records it before any submitted user input.
pub(crate) async fn queue_message_for_next_turn(
&self,
message: ResponseItem,
) -> CodexResult<String> {
let submission_id = uuid::Uuid::new_v4().to_string();
let pending_item = pending_message_input_item(&message)?;
self.codex
.session
.record_conversation_items(turn_context.as_ref(), &items)
.queue_response_items_for_next_turn(vec![pending_item])
.await;
self.codex
.session
.ensure_task_for_queued_response_items()
.await;
Ok(submission_id)
}
pub fn rollout_path(&self) -> Option<PathBuf> {
@@ -198,3 +261,15 @@ impl CodexThread {
Ok(*guard)
}
}
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {
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(),
)),
}
}
+11 -114
View File
@@ -5730,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),
@@ -5745,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),
@@ -5772,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()))
@@ -5882,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(())
}
-112
View File
@@ -106,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;
@@ -679,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();
@@ -729,111 +725,6 @@ impl ConfigBuilder {
}
}
fn config_scope_segments(scope: &[String], key: &str) -> Vec<String> {
let mut segments = scope.to_vec();
segments.push(key.to_string());
segments
}
fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec<String> {
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<ConfigEdit>,
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 = <alias value>` 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<bool> {
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::<ConfigToml>(&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(
@@ -895,9 +786,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
cwd: &AbsolutePathBuf,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
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()),
+11 -4
View File
@@ -1,3 +1,4 @@
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::event_mapping::is_contextual_user_message_content;
@@ -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 {
InterAgentInstruction::is_message_content(content)
}
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
@@ -38,6 +38,18 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
let mut h = ContextManager::new();
// Use a generous but fixed token budget; tests only rely on truncation
@@ -225,6 +237,35 @@ 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(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: 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(
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: 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 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")]);
@@ -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),
+1 -1
View File
@@ -72,7 +72,7 @@ fn parse_agent_message(
let mut content: Vec<AgentMessageContent> = 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() });
}
_ => {
+37
View File
@@ -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::<Vec<_>>();
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();
+14 -3
View File
@@ -549,7 +549,7 @@ pub fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let runtime_sandbox_provides_safety =
let environment_lacks_sandbox_protections =
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
// If the command is flagged as dangerous or we have no sandbox protection,
@@ -558,9 +558,20 @@ pub fn render_decision_for_unmatched_command(
// We prefer to prompt the user rather than outright forbid the command,
// but if the user has explicitly disabled prompts, we must
// forbid the command.
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
return match approval_policy {
AskForApproval::Never => Decision::Forbidden,
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
);
if sandbox_is_explicitly_disabled {
// If the sandbox is explicitly disabled, we should allow the command to run
Decision::Allow
} else {
Decision::Forbidden
}
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
+325 -344
View File
@@ -81,6 +81,10 @@ fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::default()
@@ -503,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]
@@ -558,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]
@@ -727,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]
@@ -770,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]
@@ -903,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,
@@ -915,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]
@@ -1239,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(
@@ -1560,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<String> {
@@ -1686,3 +1570,100 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
(unless AskForApproval::Never is specified)."#
);
}
#[tokio::test]
async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec_str(&["rm", "-rf", "/tmp/nonexistent"]),
}),
},
)
.await;
}
#[tokio::test]
async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Forbidden {
reason: "approval required by policy, but AskForApproval is set to Never".to_string(),
},
)
.await;
}
struct ExecApprovalRequirementScenario {
/// Source for the Starlark `.rules` file.
policy_src: Option<String>,
command: Vec<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
}
async fn assert_exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
let ExecApprovalRequirementScenario {
policy_src,
command,
approval_policy,
sandbox_policy,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
} = test;
let policy = match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser
.parse("test.rules", src.as_str())
.expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
};
let requirement = ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
sandbox_policy: &sandbox_policy,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
})
.await;
assert_eq!(requirement, expected_requirement);
}
+2 -1
View File
@@ -622,7 +622,8 @@ impl PluginsManager {
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
return Ok(featured_plugin_ids);
}
let featured_plugin_ids = fetch_remote_featured_plugin_ids(config, auth).await?;
let featured_plugin_ids =
fetch_remote_featured_plugin_ids(config, auth, self.restriction_product).await?;
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
Ok(featured_plugin_ids)
}
@@ -23,6 +23,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
@@ -1899,6 +1900,74 @@ plugins = true
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "chat"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(
tmp.path().to_path_buf(),
Some(Product::Chatgpt),
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(tmp.path().to_path_buf(), None);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, None)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
}
#[test]
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() {
let tmp = tempfile::tempdir().unwrap();
+6
View File
@@ -1,6 +1,7 @@
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
@@ -162,12 +163,17 @@ pub(crate) async fn fetch_remote_plugin_status(
pub async fn fetch_remote_featured_plugin_ids(
config: &Config,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
@@ -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:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:fork seed
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
06:message/user:after fork
+30
View File
@@ -153,7 +153,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<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
task: T,
) {
let task: Arc<dyn SessionTask> = Arc::new(task);
let task_kind = task.kind();
let span_name = task.span_name();
@@ -224,6 +232,22 @@ impl Session {
.await;
}
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
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<Self>, reason: TurnAbortReason) {
if let Some(mut active_turn) = self.take_active_turn().await {
for task in active_turn.drain_tasks() {
@@ -233,6 +257,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,6 +398,9 @@ 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);
+27
View File
@@ -3,6 +3,8 @@ use crate::CodexAuth;
use crate::ModelProviderInfo;
use crate::OPENAI_PROVIDER_ID;
use crate::agent::AgentControl;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
use crate::codex::Codex;
use crate::codex::CodexSpawnArgs;
use crate::codex::CodexSpawnOk;
@@ -26,6 +28,8 @@ use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
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;
@@ -606,6 +610,29 @@ 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<String> {
let thread = self.get_thread(thread_id).await?;
thread.append_message(message).await
}
pub(crate) async fn deliver_inter_agent_instruction(
&self,
thread_id: ThreadId,
instruction: InterAgentInstruction,
delivery: InterAgentDelivery,
) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?;
thread
.deliver_inter_agent_instruction(instruction, delivery)
.await
}
/// Remove a thread from the manager by ID, returning it when present.
pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option<Arc<CodexThread>> {
self.threads.write().await.remove(thread_id)
@@ -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__
@@ -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.
@@ -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<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
struct CodeModeExecArgs {
code: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
}
impl CodeModeExecuteHandler {
async fn execute(
&self,
session: std::sync::Arc<Session>,
turn: std::sync::Arc<TurnContext>,
session: std::sync::Arc<crate::codex::Session>,
turn: std::sync::Arc<crate::codex::TurnContext>,
call_id: String,
code: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
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<CodeModeExecArgs, FunctionCallError> {
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;
+187 -173
View File
@@ -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<Session>,
pub(super) turn: Arc<TurnContext>,
}
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<PathBuf>) -> 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<String, JsonValue> {
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<String, JsonValue>,
) {
self.inner.replace_stored_values(values).await;
}
pub(crate) async fn execute(
&self,
request: codex_code_mode::ExecuteRequest,
) -> Result<RuntimeResponse, String> {
self.inner.execute(request).await
}
pub(crate) async fn wait(
&self,
request: codex_code_mode::WaitRequest,
) -> Result<RuntimeResponse, String> {
self.inner.wait(request).await
}
pub(crate) async fn start_turn_worker(
&self,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<codex_code_mode::CodeModeTurnWorker> {
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<JsonValue>,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
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::<Vec<_>>()
.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<Option<usize>>,
response: RuntimeResponse,
max_output_tokens: Option<usize>,
started_at: std::time::Instant,
) -> Result<CodeModeSessionProgress, String> {
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<FunctionToolOutput, String> {
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<FunctionCallOutputContentItem>,
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<FunctionCallOutputContentItem>,
max_output_tokens_per_exec_call: Option<usize>,
max_output_tokens: Option<usize>,
) -> Vec<FunctionCallOutputContentItem> {
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<JsonValue>,
) -> Result<Vec<FunctionCallOutputContentItem>, 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<protocol::EnabledTool> {
pub(super) async fn build_enabled_tools(
exec: &ExecContext,
) -> Vec<codex_code_mode::ToolDefinition> {
let router = build_nested_router(exec).await;
let mut out = router
.specs()
@@ -238,39 +250,37 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec<protocol::EnabledTool> {
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
.filter_map(enabled_tool_from_spec)
.collect::<Vec<_>>();
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<protocol::EnabledTool> {
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<codex_code_mode::ToolDefinition> {
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<JsonValue>,
cancellation_token: tokio_util::sync::CancellationToken,
cancellation_token: CancellationToken,
) -> Result<JsonValue, FunctionCallError> {
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<ToolSpec>,
tool_name: &str,
) -> Result<protocol::CodeModeToolKind, String> {
) -> Result<codex_code_mode::CodeModeToolKind, String> {
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<ToolPayload, String> {
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)
}
}
}
@@ -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<Mutex<tokio::process::ChildStdin>>,
pub(super) stdout_task: JoinHandle<()>,
pub(super) response_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<NodeToHostMessage>>>>,
pub(super) message_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeToHostMessage>>>,
}
impl CodeModeProcess {
pub(super) async fn send(
&mut self,
request_id: &str,
message: &HostToNodeMessage,
) -> Result<NodeToHostMessage, std::io::Error> {
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<bool, std::io::Error> {
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<CodeModeProcess, std::io::Error> {
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<NodeToHostMessage>,
>::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<Mutex<tokio::process::ChildStdin>>,
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(())
}
@@ -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<String>,
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<JsonValue>,
}
#[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<EnabledTool>,
stored_values: HashMap<String, JsonValue>,
source: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
},
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<String>,
},
}
#[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<JsonValue>,
},
Terminated {
request_id: String,
content_items: Vec<JsonValue>,
},
Notify {
#[serde(flatten)]
notify: CodeModeNotify,
},
Result {
request_id: String,
content_items: Vec<JsonValue>,
stored_values: HashMap<String, JsonValue>,
#[serde(default)]
error_text: Option<String>,
#[serde(default)]
max_output_tokens_per_exec_call: Option<usize>,
},
}
pub(super) fn build_source(
user_code: &str,
enabled_tools: &[EnabledTool],
) -> Result<String, String> {
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));
}
}
@@ -0,0 +1,44 @@
use codex_code_mode::ImageDetail as CodeModeImageDetail;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
trait IntoProtocol<T> {
fn into_protocol(self) -> T;
}
pub(super) fn into_function_call_output_content_items(
items: Vec<codex_code_mode::FunctionCallOutputContentItem>,
) -> Vec<FunctionCallOutputContentItem> {
items.into_iter().map(IntoProtocol::into_protocol).collect()
}
impl IntoProtocol<ImageDetail> 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<FunctionCallOutputContentItem>
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),
}
}
}
}
}
@@ -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;
}
});
@@ -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<PathBuf>,
stored_values: Mutex<HashMap<String, JsonValue>>,
process: Arc<Mutex<Option<CodeModeProcess>>>,
next_cell_id: Mutex<u64>,
}
impl CodeModeService {
pub(crate) fn new(js_repl_node_path: Option<PathBuf>) -> 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<String, JsonValue> {
self.stored_values.lock().await.clone()
}
pub(crate) async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
*self.stored_values.lock().await = values;
}
pub(super) async fn ensure_started(
&self,
) -> Result<tokio::sync::OwnedMutexGuard<Option<CodeModeProcess>>, 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<Session>,
turn: &Arc<TurnContext>,
router: Arc<ToolRouter>,
tracker: SharedTurnDiffTracker,
) -> Option<CodeModeWorker> {
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()
}
}
@@ -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.
@@ -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"
-116
View File
@@ -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<oneshot::Sender<()>>,
}
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),
}
}
}
+24 -274
View File
@@ -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<String>,
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<String, JsonValue>,
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, JsonValue>) -> 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::<Vec<_>>();
if !item_types.is_empty() {
return format!("[{}]", item_types.join(", "));
}
}
"unknown[]".to_string()
}
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> 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::<Vec<_>>()
})
.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::<Vec<_>>();
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::<Vec<_>>();
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;
@@ -1,7 +1,15 @@
use super::*;
use crate::agent::inter_agent_instruction::InterAgentDelivery;
use crate::agent::inter_agent_instruction::InterAgentInstruction;
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;
@@ -52,12 +60,40 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let result = session
.services
.agent_control
.send_input(receiver_thread_id, input_items)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let agent_control = session.services.agent_control.clone();
let result = if turn.config.features.enabled(Feature::MultiAgentV2)
&& 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 instruction = InterAgentInstruction::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
);
agent_control
.deliver_inter_agent_instruction(
receiver_thread_id,
instruction,
if args.interrupt {
InterAgentDelivery::NextTurn
} else {
InterAgentDelivery::CurrentTurn
},
)
.await
} else {
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
@@ -7,6 +7,7 @@ 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::FileSystemSandboxPolicy;
use crate::protocol::NetworkSandboxPolicy;
@@ -14,6 +15,9 @@ use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionSource;
use crate::protocol::SubAgentSource;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_features::Feature;
@@ -24,6 +28,7 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
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 +38,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<crate::codex::Session>,
@@ -68,6 +74,31 @@ fn thread_manager() -> ThreadManager {
)
}
#[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<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
cancellation_token.cancelled().await;
None
}
}
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
where
T: ToolOutput,
@@ -337,6 +368,307 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
))
.await
.expect("send_input should accept v2 path");
let child_thread = manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
timeout(Duration::from_secs(2), async {
loop {
let history_items = child_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::OutputText { text }
if text
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
))
)
});
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);
SpawnAgentHandler
.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"}
]
})),
);
SendInputHandler
.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)));
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
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
))
)
});
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);
SpawnAgentHandler
.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;
SendInputHandler
.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::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_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
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
))
)
});
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]
+1 -2
View File
@@ -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
+6 -6
View File
@@ -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;
@@ -833,7 +830,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 {
@@ -2176,7 +2173,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(),
@@ -2647,7 +2647,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::<Vec<_>>();
enabled_tools.sort_by(|left, right| left.0.cmp(&right.0));
+2 -6
View File
@@ -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,
@@ -493,6 +493,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,
+3 -1
View File
@@ -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![
+118 -40
View File
@@ -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<T: Into<String>>(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<Vec<WritableRoot>>,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let start_tag = DeveloperInstructions::new("<permissions instructions>");
let end_tag = DeveloperInstructions::new("</permissions instructions>");
@@ -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,
+34 -1
View File
@@ -1538,6 +1538,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 +1574,11 @@ pub enum CodexErrorInfo {
ResponseTooManyFailedAttempts {
http_status_code: Option<u16>,
},
/// 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 +1587,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
@@ -2978,6 +2992,14 @@ pub enum Product {
Atlas,
}
impl Product {
pub fn to_app_platform(self) -> &'static str {
match self {
Self::Chatgpt => "chat",
Self::Codex => "codex",
Self::Atlas => "atlas",
}
}
pub fn from_session_source_name(value: &str) -> Option<Self> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
@@ -4203,6 +4225,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 {
+4 -4
View File
@@ -5008,7 +5008,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 +5090,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 +5171,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 +5246,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()
+107 -1
View File
@@ -3584,6 +3584,13 @@ impl ChatComposer {
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
let plugin_display_names: HashSet<String> =
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 {
@@ -3666,18 +3673,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 +5402,93 @@ mod tests {
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
}
#[test]
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
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 mut mention_summaries: Vec<_> = composer
.mention_items()
.into_iter()
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
.collect();
mention_summaries.sort();
assert_eq!(
mention_summaries,
vec![
(
"Google Calendar".to_string(),
Some("[Plugin]".to_string()),
Some("plugin://google-calendar@debug".to_string()),
),
(
"Google Calendar".to_string(),
Some("[Skill]".to_string()),
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
),
]
);
}
#[test]
fn plugin_mention_popup_snapshot() {
snapshot_composer_state("plugin_mention_popup", false, |composer| {
@@ -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::<Vec<_>>()
);
assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16);
}
}
+19 -4
View File
@@ -825,8 +825,10 @@ impl BottomPane {
&mut self,
queued: Vec<String>,
pending_steers: Vec<String>,
rejected_steers: Vec<String>,
) {
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);
@@ -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<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
/// 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<dyn Renderable> {
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());
@@ -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<String> = 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::<Vec<_>>()
);
assert_eq!(
popup.calculate_required_height(72),
(MAX_POPUP_ROWS as u16) + 2
);
}
}
@@ -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,
]
}
@@ -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,
]
}
+213 -58
View File
@@ -155,6 +155,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 +197,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)
}
@@ -720,6 +729,7 @@ pub(crate) struct ChatWidget {
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
connectors_prefetch_in_flight: bool,
connectors_force_refetch_pending: bool,
pending_mcp_output_requests: usize,
plugins_cache: PluginsCacheState,
plugins_fetch_state: PluginListFetchState,
// Queue of interruptive UI events deferred during an active write cycle
@@ -755,6 +765,8 @@ pub(crate) struct ChatWidget {
suppress_session_configured_redraw: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
// User messages that tried to steer a non-regular turn and must be retried first.
rejected_steers_queue: VecDeque<UserMessage>,
// 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 +927,11 @@ impl ThreadComposerState {
pub(crate) struct ThreadInputState {
composer: Option<ThreadComposerState>,
pending_steers: VecDeque<UserMessage>,
rejected_steers_queue: VecDeque<UserMessage>,
queued_user_messages: VecDeque<UserMessage>,
current_collaboration_mode: CollaborationMode,
active_collaboration_mask: Option<CollaborationModeMask>,
task_running: bool,
agent_turn_running: bool,
}
@@ -1456,9 +1470,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 +1814,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 +1836,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 +1908,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<UserMessage> {
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<UserMessage> {
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 +2239,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 +2258,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 +2316,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<UserMessage> {
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 +2328,12 @@ impl ChatWidget {
mention_bindings: self.bottom_pane.composer_mention_bindings(),
};
let mut to_merge: Vec<UserMessage> = self
.pending_steers
.drain(..)
.map(|steer| steer.user_message)
.collect();
let mut to_merge: Vec<UserMessage> = 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 +2379,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<ThreadInputState>) {
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 +2421,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 +2452,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 +3693,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,6 +3744,7 @@ 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(),
interrupts: InterruptManager::new(),
@@ -3680,6 +3760,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 +3895,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,6 +3946,7 @@ 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(),
interrupts: InterruptManager::new(),
@@ -3886,6 +3967,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 +4089,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,6 +4140,7 @@ 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(),
interrupts: InterruptManager::new(),
@@ -4074,6 +4156,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 +4299,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 +4525,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 => {
@@ -4947,10 +5033,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 +5048,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,
@@ -5375,7 +5452,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 +5466,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 +5829,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 +5848,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<String> = 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<String>) {
@@ -5943,10 +6033,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 +8454,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);
}
}
@@ -8793,9 +8879,14 @@ impl ChatWidget {
#[cfg(test)]
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
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 +8922,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<String, connectors::AppInfo> = 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::<Vec<_>>();
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) {
@@ -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
@@ -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

Some files were not shown because too many files have changed in this diff Show More