Merge branch 'main' into codex/self-serve-business-usage-limit-copy

This commit is contained in:
dhruvgupta-oai
2026-03-23 13:47:30 -04:00
committed by GitHub
58 changed files with 2479 additions and 213 deletions
+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\"]}}",
+12 -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]]
@@ -3821,7 +3821,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4066,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]]
@@ -5511,7 +5511,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6274,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]]
@@ -6918,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]]
@@ -8340,7 +8340,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9754,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",
@@ -9773,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]]
@@ -11219,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]]
@@ -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;
}
+1 -1
View File
@@ -479,7 +479,7 @@ pub async fn run_main_with_transport(
range: None,
});
}
if let Some(warning) = codex_core::config::missing_system_bwrap_warning() {
if let Some(warning) = codex_core::config::system_bwrap_warning() {
config_warnings.push(ConfigWarningNotification {
summary: warning,
details: None,
+5 -3
View File
@@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the
more specific writable child must reopen under a denied parent.
The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and
falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is
missing, Codex also surfaces a startup warning through its normal notification
path instead of printing directly from the sandbox helper.
supports the required argv-rewrite flags, and falls back to the vendored
bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is
missing or too old to support the required flags, Codex also surfaces a startup
warning through its normal notification path instead of printing directly from
the sandbox helper.
### Windows
+70 -13
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;
@@ -3859,6 +3892,21 @@ 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())
@@ -4526,26 +4574,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;
}
}
}
+38
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;
@@ -4507,6 +4508,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;
+61 -8
View File
@@ -5582,16 +5582,69 @@ shell_tool = true
Ok(())
}
#[cfg(target_os = "linux")]
#[test]
fn missing_system_bwrap_warning_matches_system_bwrap_presence() {
#[cfg(target_os = "linux")]
assert_eq!(
missing_system_bwrap_warning().is_some(),
!Path::new("/usr/bin/bwrap").is_file()
);
fn system_bwrap_warning_reports_missing_system_bwrap() {
let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap"))
.expect("missing system bwrap should emit a warning");
#[cfg(not(target_os = "linux"))]
assert!(missing_system_bwrap_warning().is_none());
assert!(warning.contains("could not find system bubblewrap"));
}
#[cfg(target_os = "linux")]
#[test]
fn system_bwrap_warning_reports_too_old_system_bwrap() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo 'usage: bwrap [OPTION...] COMMAND'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
let warning = system_bwrap_warning_for_path(fake_bwrap_path)
.expect("old system bwrap should emit a warning");
assert!(warning.contains("too old to support `--argv0`"));
}
#[cfg(target_os = "linux")]
#[test]
fn system_bwrap_warning_skips_supported_system_bwrap() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo ' --argv0 PROGRAM'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn system_bwrap_warning_is_disabled_off_linux() {
assert!(system_bwrap_warning().is_none());
}
#[cfg(target_os = "linux")]
fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::NamedTempFile;
// Linux rejects exec-ing a file that is still open for writing.
let path = NamedTempFile::new().expect("temp file").into_temp_path();
fs::write(&path, contents).expect("write fake bwrap");
let permissions = fs::Permissions::from_mode(0o755);
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
path
}
#[tokio::test]
+36 -9
View File
@@ -96,6 +96,8 @@ use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use std::process::Command;
use crate::config::permissions::compile_permission_profile;
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
@@ -153,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
];
#[cfg(target_os = "linux")]
pub fn missing_system_bwrap_warning() -> Option<String> {
if Path::new(SYSTEM_BWRAP_PATH).is_file() {
None
} else {
Some(format!(
"Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime."
))
}
pub fn system_bwrap_warning() -> Option<String> {
system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH))
}
#[cfg(not(target_os = "linux"))]
pub fn missing_system_bwrap_warning() -> Option<String> {
pub fn system_bwrap_warning() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option<String> {
if !system_bwrap_path.is_file() {
return Some(format!(
"Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
system_bwrap_path.display()
));
}
if system_bwrap_supports_argv0(system_bwrap_path) {
return None;
}
Some(format!(
"Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
system_bwrap_path.display()
))
}
#[cfg(target_os = "linux")]
fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool {
// bubblewrap added `--argv0` in v0.9.0:
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
let output = match Command::new(system_bwrap_path).arg("--help").output() {
Ok(output) => output,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
stdout.contains("--argv0") || stderr.contains("--argv0")
}
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
let trimmed = raw.trim();
+1 -1
View File
@@ -669,7 +669,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
// Print the effective configuration and initial request so users can see what Codex
// is using.
event_processor.print_config_summary(&config, &prompt_summary, &session_configured);
if !json_mode && let Some(message) = codex_core::config::missing_system_bwrap_warning() {
if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() {
let _ = event_processor.process_event(Event {
id: String::new(),
msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }),
+12 -8
View File
@@ -8,19 +8,23 @@ This crate is responsible for producing:
- this should also be true of the `codex` multitool CLI
On Linux, the bubblewrap pipeline prefers the system `/usr/bin/bwrap` whenever
it is available. If `/usr/bin/bwrap` is missing, the helper still falls back to
it is available and supports the required argv-rewrite flags. If `/usr/bin/bwrap`
is missing or too old to support the required flags, the helper falls back to
the vendored bubblewrap path compiled into this binary.
Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing so users
know it is falling back to the vendored helper.
Codex also surfaces a startup warning when `/usr/bin/bwrap` is missing or too
old to support the required flags so users know it is falling back to the
vendored helper.
**Current Behavior**
- Legacy `SandboxPolicy` / `sandbox_mode` configs remain supported.
- Bubblewrap is the default filesystem sandbox pipeline.
- If `/usr/bin/bwrap` is present, the helper uses it.
- If `/usr/bin/bwrap` is missing, the helper falls back to the vendored
bubblewrap path.
- If `/usr/bin/bwrap` is missing, Codex also surfaces a startup warning instead
of printing directly from the sandbox helper.
- If `/usr/bin/bwrap` is present and supports the required argv-rewrite flags,
the helper uses it.
- If `/usr/bin/bwrap` is missing or too old to support the required flags, the
helper falls back to the vendored bubblewrap path.
- If `/usr/bin/bwrap` is missing or too old to support the required flags,
Codex also surfaces a startup warning instead of printing directly from the
sandbox helper.
- Legacy Landlock + mount protections remain available as an explicit legacy
fallback path.
- Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`)
+88 -3
View File
@@ -4,6 +4,8 @@ use std::os::fd::AsRawFd;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
use crate::vendored_bwrap::exec_vendored_bwrap;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -24,17 +26,41 @@ pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
}
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
if !Path::new(SYSTEM_BWRAP_PATH).is_file() {
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
LAUNCHER
.get_or_init(|| preferred_bwrap_launcher_for_path(Path::new(SYSTEM_BWRAP_PATH)))
.clone()
}
fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher {
if !system_bwrap_supports_argv0(system_bwrap_path) {
return BubblewrapLauncher::Vendored;
}
let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(SYSTEM_BWRAP_PATH) {
let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) {
Ok(path) => path,
Err(err) => panic!("failed to normalize system bubblewrap path {SYSTEM_BWRAP_PATH}: {err}"),
Err(err) => panic!(
"failed to normalize system bubblewrap path {}: {err}",
system_bwrap_path.display()
),
};
BubblewrapLauncher::System(system_bwrap_path)
}
fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool {
// bubblewrap added `--argv0` in v0.9.0:
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
// Older distro packages (for example Ubuntu 20.04/22.04) ship builds that
// reject `--argv0`, so prefer the vendored build in that case.
let output = match Command::new(system_bwrap_path).arg("--help").output() {
Ok(output) => output,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
stdout.contains("--argv0") || stderr.contains("--argv0")
}
fn exec_system_bwrap(
program: &AbsolutePathBuf,
argv: Vec<String>,
@@ -100,7 +126,57 @@ fn clear_cloexec(fd: libc::c_int) {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::NamedTempFile;
use tempfile::TempPath;
#[test]
fn prefers_system_bwrap_when_help_lists_argv0() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo ' --argv0 PROGRAM'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute");
assert_eq!(
preferred_bwrap_launcher_for_path(fake_bwrap_path),
BubblewrapLauncher::System(expected)
);
}
#[test]
fn falls_back_to_vendored_when_system_bwrap_lacks_argv0() {
let fake_bwrap = write_fake_bwrap(
r#"#!/bin/sh
if [ "$1" = "--help" ]; then
echo 'usage: bwrap [OPTION...] COMMAND'
exit 0
fi
exit 1
"#,
);
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
assert_eq!(
preferred_bwrap_launcher_for_path(fake_bwrap_path),
BubblewrapLauncher::Vendored
);
}
#[test]
fn falls_back_to_vendored_when_system_bwrap_is_missing() {
assert_eq!(
preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")),
BubblewrapLauncher::Vendored
);
}
#[test]
fn preserved_files_are_made_inheritable_for_system_exec() {
@@ -131,4 +207,13 @@ mod tests {
}
flags
}
fn write_fake_bwrap(contents: &str) -> TempPath {
// Linux rejects exec-ing a file that is still open for writing.
let path = NamedTempFile::new().expect("temp file").into_temp_path();
fs::write(&path, contents).expect("write fake bwrap");
let permissions = fs::Permissions::from_mode(0o755);
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
path
}
}
+26 -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
@@ -4211,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 {
+7 -7
View File
@@ -398,8 +398,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config)
)));
}
fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) {
let Some(message) = codex_core::config::missing_system_bwrap_warning() else {
fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) {
let Some(message) = codex_core::config::system_bwrap_warning() else {
return;
};
@@ -2197,7 +2197,7 @@ impl App {
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
emit_missing_system_bwrap_warning(&app_event_tx);
emit_system_bwrap_warning(&app_event_tx);
emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await;
tui.set_notification_method(config.tui_notification_method);
@@ -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(""));
@@ -304,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
);
}
}
@@ -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,
]
}
+113 -31
View File
@@ -765,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
@@ -925,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,
}
@@ -1810,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
@@ -1832,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 {
@@ -1904,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 {
@@ -2275,7 +2323,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;
}
@@ -2287,11 +2335,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()
@@ -2337,14 +2386,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;
@@ -2376,13 +2428,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(),
@@ -2396,6 +2459,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();
}
@@ -3700,6 +3767,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,
@@ -3906,6 +3974,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,
@@ -4094,6 +4163,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,
@@ -4236,9 +4306,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();
@@ -4462,6 +4532,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 => {
@@ -4967,10 +5040,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 {
@@ -4985,12 +5055,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,
@@ -5395,7 +5459,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);
@@ -5407,8 +5473,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 => {
@@ -5767,7 +5836,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).
@@ -5786,8 +5855,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>) {
@@ -8809,9 +8886,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()
}
@@ -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
+334 -9
View File
@@ -89,6 +89,7 @@ use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
@@ -1536,6 +1537,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
assert!(chat.is_review_mode);
}
#[tokio::test]
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later"));
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
assert_eq!(chat.pending_steers.len(), 2);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected second running-turn steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected-1".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
chat.handle_codex_event(Event {
id: "steer-rejected-2".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec![
"review follow-up one",
"review follow-up two",
"queued later"
]
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "review-exit".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
});
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one\nreview follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "turn-complete-2".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-2".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued later".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued draft submit after rejected steers, got {other:?}"),
}
}
#[tokio::test]
async fn live_agent_message_renders_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
@@ -1915,6 +2041,7 @@ async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
@@ -3707,9 +3834,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
queued_user_messages: VecDeque::new(),
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: true,
agent_turn_running: true,
}));
@@ -3724,6 +3853,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let mut pending_steers = VecDeque::new();
pending_steers.push_back(UserMessage::from("pending steer"));
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
queued_user_messages.push_back(UserMessage::from("queued draft"));
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
rejected_steers_queue,
queued_user_messages,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: false,
agent_turn_running: false,
}));
assert_eq!(
chat.queued_user_message_texts(),
vec!["already rejected", "queued draft"]
);
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
"pending steer"
);
}
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4128,6 +4289,97 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn submit_user_message_queues_while_compaction_turn_is_running() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-started".to_string(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.submit_user_message(UserMessage::from("queued while compacting"));
assert_eq!(chat.pending_steers.len(), 1);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn compact steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec!["queued while compacting"]
);
chat.handle_codex_event(Event {
id: "turn-complete".to_string(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"),
}
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Compact);
assert!(chat.bottom_pane.is_task_running());
match rx.try_recv() {
Ok(AppEvent::CodexOp(Op::Compact)) => {}
other => panic!("expected compact op to be submitted, got {other:?}"),
}
chat.bottom_pane.set_composer_text(
"queued before compact turn start".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.pending_steers.is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued before compact turn start"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -11568,9 +11820,17 @@ async fn chatwidget_tall() {
}
#[tokio::test]
async fn enter_queues_user_messages_while_review_is_running() {
async fn enter_submits_steer_while_review_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -11582,19 +11842,28 @@ async fn enter_queues_user_messages_while_review_is_running() {
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
"Queued while /review is running.".to_string(),
"Steer submitted while /review was running.".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"Queued while /review is running."
chat.pending_steers.front().unwrap().user_message.text,
"Steer submitted while /review was running."
);
assert!(chat.pending_steers.is_empty());
assert_no_submit_op(&mut op_rx);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "Steer submitted while /review was running.".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -11602,6 +11871,14 @@ async fn enter_queues_user_messages_while_review_is_running() {
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -11612,9 +11889,57 @@ async fn review_queues_user_messages_snapshot() {
});
let _ = drain_insert_history(&mut rx);
chat.queue_user_message(UserMessage::from(
"Queued while /review is running.".to_string(),
chat.submit_user_message(UserMessage::from(
"Steer submitted while /review was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
}
#[tokio::test]
async fn compact_queues_user_messages_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.submit_user_message(UserMessage::from(
"Steer submitted while /compact was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;
+64 -10
View File
@@ -48,7 +48,9 @@ use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -63,6 +65,7 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -435,8 +438,8 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config)
)));
}
fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) {
let Some(message) = codex_core::config::missing_system_bwrap_warning() else {
fn emit_system_bwrap_warning(app_event_tx: &AppEventSender) {
let Some(message) = codex_core::config::system_bwrap_warning() else {
return;
};
@@ -967,6 +970,18 @@ fn normalize_harness_overrides_for_cwd(
Ok(overrides)
}
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
let TypedRequestError::Server { source, .. } = error else {
return None;
};
let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?;
matches!(
turn_error.codex_error_info,
Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. })
)
.then_some(turn_error)
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -1950,9 +1965,21 @@ impl App {
personality,
} => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server
match app_server
.turn_steer(thread_id, turn_id, items.to_vec())
.await?;
.await
{
Ok(_) => {}
Err(error) => {
if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) {
if !self.chat_widget.enqueue_rejected_steer() {
self.chat_widget.add_error_message(turn_error.message);
}
} else {
return Err(error.into());
}
}
}
} else {
app_server
.turn_start(
@@ -2794,7 +2821,7 @@ impl App {
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
emit_missing_system_bwrap_warning(&app_event_tx);
emit_system_bwrap_warning(&app_event_tx);
tui.set_notification_method(config.tui_notification_method);
let harness_overrides =
@@ -5170,10 +5197,12 @@ mod tests {
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -5186,6 +5215,7 @@ mod tests {
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as AppServerUserInput;
@@ -5735,7 +5765,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()
@@ -5785,7 +5815,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()
@@ -5834,7 +5864,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()
@@ -5881,7 +5911,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()
@@ -5951,7 +5981,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()
@@ -8017,6 +8047,30 @@ guardian_approval = true
);
}
#[test]
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
let turn_error = AppServerTurnError {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: AppServerNonSteerableTurnKind::Review,
}),
additional_details: None,
};
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: turn_error.message.clone(),
data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")),
},
};
assert_eq!(
active_turn_not_steerable_turn_error(&error),
Some(turn_error)
);
}
#[test]
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
let mut presets = all_model_presets();
@@ -1,6 +1,7 @@
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
@@ -429,7 +430,7 @@ impl AppServerSession {
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> Result<TurnSteerResponse> {
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnSteer {
@@ -441,7 +442,6 @@ impl AppServerSession {
},
})
.await
.wrap_err("turn/steer failed in app-server TUI")
}
pub(crate) async fn thread_set_name(
@@ -3599,6 +3599,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 {
@@ -3681,18 +3688,30 @@ impl ChatComposer {
if !connector.is_accessible || !connector.is_enabled {
continue;
}
let plugin_backed_connector = connector
.plugin_display_names
.iter()
.any(|name| plugin_display_names.contains(&name.to_ascii_lowercase()));
if plugin_backed_connector {
continue;
}
let display_name = connectors::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_core::connectors::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
let category_tag = if connector.plugin_display_names.is_empty() {
"[App]".to_string()
} else {
"[Plugin]".to_string()
};
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
category_tag: Some("[App]".to_string()),
category_tag: Some(category_tag),
sort_rank: 1,
});
}
@@ -5398,6 +5417,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
@@ -821,8 +821,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();
}
@@ -1143,7 +1145,8 @@ impl BottomPane {
}
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|| !self.pending_input_preview.pending_steers.is_empty();
|| !self.pending_input_preview.pending_steers.is_empty()
|| !self.pending_input_preview.rejected_steers.is_empty();
let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
@@ -1546,7 +1549,11 @@ mod tests {
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
let width = 48;
let height = pane.desired_height(width);
@@ -1573,7 +1580,11 @@ mod tests {
});
pane.set_task_running(true);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
pane.hide_status_indicator();
let width = 48;
@@ -1601,7 +1612,11 @@ mod tests {
});
pane.set_task_running(true);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
let width = 48;
let height = pane.desired_height(width);
@@ -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(""));
@@ -304,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
);
}
}
@@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 52, height: 8 },
area: Rect { x: 0, y: 0, width: 52, height: 11 },
content: [
"• Messages to be submitted after next tool call ",
" (press esc to interrupt and send immediately) ",
" ↳ Please continue. ",
" ↳ Check the last command output. ",
" ",
"• Messages to be submitted at end of turn ",
" ↳ Rejected steer that will be retried. ",
" ",
"• Queued follow-up messages ",
" ↳ Queued follow-up question ",
" ⌥ + ↑ edit last queued message ",
@@ -26,9 +29,13 @@ Buffer {
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+124 -32
View File
@@ -807,6 +807,8 @@ pub(crate) struct ChatWidget {
suppress_initial_user_message_submit: 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
@@ -958,9 +960,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,
}
@@ -2172,7 +2176,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
@@ -2194,7 +2198,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 {
@@ -2266,6 +2270,57 @@ impl ChatWidget {
});
}
fn has_queued_follow_up_messages(&self) -> bool {
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
}
fn pop_next_queued_user_message(&mut self) -> Option<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);
self.refresh_pending_input_preview();
true
}
#[cfg(test)]
fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool {
matches!(
codex_error_info,
CoreCodexErrorInfo::ActiveTurnNotSteerable { .. }
) && self.enqueue_rejected_steer()
}
fn handle_app_server_steer_rejected_error(
&mut self,
codex_error_info: &AppServerCodexErrorInfo,
) -> bool {
matches!(
codex_error_info,
AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }
) && self.enqueue_rejected_steer()
}
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
let items = vec![
SelectionItem {
@@ -2515,7 +2570,11 @@ impl ChatWidget {
message: String,
codex_error_info: Option<AppServerCodexErrorInfo>,
) {
if let Some(info) = codex_error_info
if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
{
} else if let Some(info) = codex_error_info
.as_ref()
.and_then(app_server_rate_limit_error_kind)
{
@@ -2655,7 +2714,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;
}
@@ -2667,11 +2726,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()
@@ -2717,14 +2777,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;
@@ -2756,13 +2819,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(),
@@ -2776,6 +2850,9 @@ impl ChatWidget {
self.turn_sleep_inhibitor
.set_turn_running(self.agent_turn_running);
self.update_task_running_state();
if restored_task_running && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.refresh_pending_input_preview();
self.request_redraw();
}
@@ -4251,6 +4328,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,
@@ -4388,9 +4466,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();
@@ -4614,6 +4692,9 @@ impl ChatWidget {
}
SlashCommand::Compact => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
@@ -5106,10 +5187,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 {
@@ -5124,12 +5202,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,
@@ -6413,7 +6485,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);
@@ -6425,8 +6499,13 @@ impl ChatWidget {
message,
codex_error_info,
}) => {
if let Some(info) = codex_error_info
&& let Some(kind) = core_rate_limit_error_kind(&info)
if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_steer_rejected_error(info))
{
} else if let Some(kind) = codex_error_info
.as_ref()
.and_then(core_rate_limit_error_kind)
{
match kind {
RateLimitErrorKind::ServerOverloaded => {
@@ -6791,7 +6870,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).
@@ -6810,8 +6889,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>) {
@@ -10042,9 +10129,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()
}
@@ -0,0 +1,21 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
↳ Steer submitted while /compact was running.
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -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
+344 -9
View File
@@ -112,6 +112,7 @@ use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
@@ -1560,6 +1561,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
assert!(chat.is_review_mode);
}
#[tokio::test]
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later"));
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
assert_eq!(chat.pending_steers.len(), 2);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected second running-turn steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected-1".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
chat.handle_codex_event(Event {
id: "steer-rejected-2".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec![
"review follow-up one",
"review follow-up two",
"queued later"
]
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "review-exit".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
});
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one\nreview follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "turn-complete-2".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-2".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued later".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued draft submit after rejected steers, got {other:?}"),
}
}
#[tokio::test]
async fn live_agent_message_renders_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
@@ -1934,6 +2060,7 @@ async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
@@ -3716,9 +3843,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
queued_user_messages: VecDeque::new(),
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: true,
agent_turn_running: true,
}));
@@ -3733,6 +3862,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let mut pending_steers = VecDeque::new();
pending_steers.push_back(UserMessage::from("pending steer"));
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
queued_user_messages.push_back(UserMessage::from("queued draft"));
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
rejected_steers_queue,
queued_user_messages,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: false,
agent_turn_running: false,
}));
assert_eq!(
chat.queued_user_message_texts(),
vec!["already rejected", "queued draft"]
);
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
"pending steer"
);
}
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4131,6 +4292,107 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn submit_user_message_queues_while_compaction_turn_is_running() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
},
}),
None,
);
chat.submit_user_message(UserMessage::from("queued while compacting"));
assert_eq!(chat.pending_steers.len(), 1);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn compact steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec!["queued while compacting"]
);
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::Completed,
error: None,
},
}),
None,
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"),
}
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Compact);
assert!(chat.bottom_pane.is_task_running());
match rx.try_recv() {
Ok(AppEvent::CodexOp(Op::Compact)) => {}
other => panic!("expected compact op to be submitted, got {other:?}"),
}
chat.bottom_pane.set_composer_text(
"queued before compact turn start".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.pending_steers.is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued before compact turn start"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -11971,9 +12233,17 @@ async fn chatwidget_tall() {
}
#[tokio::test]
async fn enter_queues_user_messages_while_review_is_running() {
async fn enter_submits_steer_while_review_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -11985,19 +12255,28 @@ async fn enter_queues_user_messages_while_review_is_running() {
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
"Queued while /review is running.".to_string(),
"Steer submitted while /review was running.".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"Queued while /review is running."
chat.pending_steers.front().unwrap().user_message.text,
"Steer submitted while /review was running."
);
assert!(chat.pending_steers.is_empty());
assert_no_submit_op(&mut op_rx);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "Steer submitted while /review was running.".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -12005,6 +12284,14 @@ async fn enter_queues_user_messages_while_review_is_running() {
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -12015,9 +12302,57 @@ async fn review_queues_user_messages_snapshot() {
});
let _ = drain_insert_history(&mut rx);
chat.queue_user_message(UserMessage::from(
"Queued while /review is running.".to_string(),
chat.submit_user_message(UserMessage::from(
"Steer submitted while /review was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
}
#[tokio::test]
async fn compact_queues_user_messages_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.submit_user_message(UserMessage::from(
"Steer submitted while /compact was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;