From e838645fa264ef108bb66b74ad284224d2a10ac0 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 23 Mar 2026 10:19:44 -0700 Subject: [PATCH] tui: queue follow-ups during manual /compact (#15259) ## Summary - queue input after the user submits `/compact` until that manual compact turn ends - mirror the same behavior in the app-server TUI - add regressions for input queued before compact starts and while it is running Co-authored-by: Codex --- .../schema/json/ServerNotification.json | 29 ++ .../codex_app_server_protocol.schemas.json | 29 ++ .../codex_app_server_protocol.v2.schemas.json | 29 ++ .../schema/json/v2/ErrorNotification.json | 29 ++ .../schema/json/v2/ReviewStartResponse.json | 29 ++ .../schema/json/v2/ThreadForkResponse.json | 29 ++ .../schema/json/v2/ThreadListResponse.json | 29 ++ .../json/v2/ThreadMetadataUpdateResponse.json | 29 ++ .../schema/json/v2/ThreadReadResponse.json | 29 ++ .../schema/json/v2/ThreadResumeResponse.json | 29 ++ .../json/v2/ThreadRollbackResponse.json | 29 ++ .../schema/json/v2/ThreadStartResponse.json | 29 ++ .../json/v2/ThreadStartedNotification.json | 29 ++ .../json/v2/ThreadUnarchiveResponse.json | 29 ++ .../json/v2/TurnCompletedNotification.json | 29 ++ .../schema/json/v2/TurnStartResponse.json | 29 ++ .../json/v2/TurnStartedNotification.json | 29 ++ .../schema/typescript/v2/CodexErrorInfo.ts | 3 +- .../typescript/v2/NonSteerableTurnKind.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 46 +++ codex-rs/app-server/README.md | 12 +- .../app-server/src/codex_message_processor.rs | 39 +- codex-rs/core/src/codex.rs | 83 +++- codex-rs/core/src/codex_tests.rs | 38 ++ codex-rs/protocol/src/protocol.rs | 27 +- codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/bottom_pane/mod.rs | 23 +- .../src/bottom_pane/pending_input_preview.rs | 49 ++- ..._pending_steers_above_queued_messages.snap | 17 +- codex-rs/tui/src/chatwidget.rs | 144 +++++-- ...compact_queues_user_messages_snapshot.snap | 21 ++ ..._review_queues_user_messages_snapshot.snap | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 343 ++++++++++++++++- codex-rs/tui_app_server/src/app.rs | 68 +++- .../tui_app_server/src/app_server_session.rs | 4 +- .../tui_app_server/src/bottom_pane/mod.rs | 23 +- .../src/bottom_pane/pending_input_preview.rs | 49 ++- ..._pending_steers_above_queued_messages.snap | 17 +- codex-rs/tui_app_server/src/chatwidget.rs | 156 ++++++-- ...compact_queues_user_messages_snapshot.snap | 21 ++ ..._review_queues_user_messages_snapshot.snap | 6 +- .../tui_app_server/src/chatwidget/tests.rs | 353 +++++++++++++++++- 43 files changed, 1898 insertions(+), 157 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5b06ab539..7d192f0b0 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -514,6 +514,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -1623,6 +1645,13 @@ ], "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c24c8ac24..6f7124751 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5658,6 +5658,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/v2/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -9115,6 +9137,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c479da94e..a9c3b9663 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2251,6 +2251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -5863,6 +5885,13 @@ }, "type": "object" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "OverriddenMetadata": { "properties": { "effectiveValue": true, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 264a901c0..032743d55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -112,9 +112,38 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "TurnError": { "properties": { "additionalDetails": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 98b485b57..2e0c3605e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 774042191..5ae0c5a12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 55f02fedb..126d78603 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 300f8d1f3..dfdab228d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 6c6597a66..8f48dee4b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 35a41983a..edccc337d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 35e03397b..cc41aac27 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 568c65456..c3b50fee3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -196,6 +196,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -589,6 +611,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 971233fcd..224015039 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 94046cd18..41b0d2d40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -134,6 +134,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -520,6 +542,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index b0220247a..770cc920c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index cd9f63bb6..7f1c3e494 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3cc16db92..761ddc9a6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -131,6 +131,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -494,6 +516,13 @@ } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 1ff409a41..20dc3c519 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; /** * This translation layer make sure that we expose codex error code in camel case. @@ -8,4 +9,4 @@ * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts new file mode 100644 index 000000000..2624df2ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NonSteerableTurnKind = "review" | "compact"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d9cc4758b..c649aec06 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 57017833a..a1d5e5562 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; @@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core { }; } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + /// This translation layer make sure that we expose codex error code in camel case. /// /// When an upstream HTTP status is available (for example, from the Responses API or a provider), @@ -167,6 +176,13 @@ pub enum CodexErrorInfo { #[ts(rename = "httpStatusCode")] http_status_code: Option, }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, Other, } @@ -193,11 +209,25 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } CoreCodexErrorInfo::Other => CodexErrorInfo::Other, } } } +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, )] @@ -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 { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3248a4442..4d139b7f7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1b02e4bb6..8917f392d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -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; } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b8..099edb7c0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -186,9 +186,41 @@ mod rollout_reconstruction_tests; pub enum SteerInputError { NoActiveTurn(Vec), ExpectedTurnMismatch { expected: String, actual: String }, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, EmptyInput, } +impl SteerInputError { + fn to_error_event(&self) -> ErrorEvent { + match self { + Self::NoActiveTurn(_) => ErrorEvent { + message: "no active turn to steer".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent { + message: format!("expected active turn id `{expected}` but found `{actual}`"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + Self::ActiveTurnNotSteerable { turn_kind } => { + let turn_kind_label = match turn_kind { + NonSteerableTurnKind::Review => "review", + NonSteerableTurnKind::Compact => "compact", + }; + ErrorEvent { + message: format!("cannot steer a {turn_kind_label} turn"), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: *turn_kind, + }), + } + } + Self::EmptyInput => ErrorEvent { + message: "input must not be empty".to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }, + } + } +} + /// Notes from the previous real user turn. /// /// Conceptually this is the same role that `previous_model` used to fill, but @@ -333,6 +365,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; @@ -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(¤t_context) + Ok(_) => current_context.session_telemetry.user_prompt(&items), + Err(SteerInputError::NoActiveTurn(items)) => { + current_context.session_telemetry.user_prompt(&items); + sess.refresh_mcp_servers_if_requested(¤t_context) + .await; + sess.spawn_task( + Arc::clone(¤t_context), + items, + crate::tasks::RegularTask::new(), + ) .await; - sess.spawn_task( - Arc::clone(¤t_context), - items, - crate::tasks::RegularTask::new(), - ) - .await; + } + Err(err) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(err.to_error_event()), + }) + .await; + } } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e2ad0982b..2305cb1fa 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -24,6 +24,7 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; @@ -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; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f..c88c4ecee 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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, }, + /// 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 { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 358b1fe5b..4086efd98 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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() diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c9e16b41..eda37fe9c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -825,8 +825,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1153,7 +1155,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1556,7 +1559,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1583,7 +1590,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1611,7 +1622,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 1f38a1777..b9690c28e 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -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()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 12744049f..77d57c3f4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4a6ef9286..7a42b5c26 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -925,9 +927,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -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 { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + if !self.bottom_pane.is_task_running() { + // Will drain rejected_steers_queue in case the steer rejection arrives after task completion + self.maybe_send_next_queued_input(); + } + self.refresh_pending_input_preview(); + true + } + + fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool { + matches!( + codex_error_info, + CodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2268,7 +2316,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - 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; } @@ -2280,11 +2328,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2330,14 +2379,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + 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; @@ -2369,13 +2421,24 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.pending_steers.clear(); - self.queued_user_messages = input_state.pending_steers; - self.queued_user_messages - .extend(input_state.queued_user_messages); + self.pending_steers = input_state + .pending_steers + .into_iter() + .map(|user_message| PendingSteer { + compare_key: PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + }, + user_message, + }) + .collect(); + self.rejected_steers_queue = input_state.rejected_steers_queue; + self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; self.pending_steers.clear(); + self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2389,6 +2452,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_terminal_title(); + } self.refresh_pending_input_preview(); self.request_redraw(); } @@ -3693,6 +3760,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -3899,6 +3967,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4087,6 +4156,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, queued_message_edit_binding, @@ -4229,9 +4299,9 @@ impl ChatWidget { if key_event.kind == KeyEventKind::Press && self.queued_message_edit_binding.is_press(key_event) - && !self.queued_user_messages.is_empty() + && self.has_queued_follow_up_messages() { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); @@ -4455,6 +4525,9 @@ impl ChatWidget { } SlashCommand::Compact => { self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { @@ -4960,10 +5033,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() - || self.bottom_pane.is_task_running() - || self.is_review_mode - { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { @@ -4978,12 +5048,6 @@ impl ChatWidget { self.refresh_pending_input_preview(); return; } - if self.is_review_mode { - self.queued_user_messages.push_back(user_message); - self.refresh_pending_input_preview(); - return; - } - let UserMessage { text, local_images, @@ -5388,7 +5452,9 @@ impl ChatWidget { } EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, .. - }) => self.on_task_complete(last_agent_message, from_replay), + }) => { + self.on_task_complete(last_agent_message, from_replay); + } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); @@ -5400,8 +5466,11 @@ impl ChatWidget { message, codex_error_info, }) => { - if let Some(info) = codex_error_info - && let Some(kind) = rate_limit_error_kind(&info) + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_steer_rejected_error(info)) + { + } else if let Some(kind) = codex_error_info.as_ref().and_then(rate_limit_error_kind) { match kind { RateLimitErrorKind::ServerOverloaded => { @@ -5760,7 +5829,7 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { + if let Some(user_message) = self.pop_next_queued_user_message() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). @@ -5779,8 +5848,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = 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) { @@ -8802,9 +8879,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 000000000..de6cfadda --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 79c08c42e..e51458566 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ed37fc5e7..65daefc91 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 79c15c29a..e00a9604f 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -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; @@ -967,6 +970,18 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { + let TypedRequestError::Server { source, .. } = error else { + return None; + }; + let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?; + matches!( + turn_error.codex_error_info, + Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }) + ) + .then_some(turn_error) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -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( @@ -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(); diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index c8a24acff..0da85bc61 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -1,6 +1,7 @@ use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; @@ -429,7 +430,7 @@ impl AppServerSession { thread_id: ThreadId, turn_id: String, items: Vec, - ) -> Result { + ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { @@ -441,7 +442,6 @@ impl AppServerSession { }, }) .await - .wrap_err("turn/steer failed in app-server TUI") } pub(crate) async fn thread_set_name( diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index 2531f8586..dd90bc11b 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -821,8 +821,10 @@ impl BottomPane { &mut self, queued: Vec, pending_steers: Vec, + rejected_steers: Vec, ) { self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1143,7 +1145,8 @@ impl BottomPane { } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() - || !self.pending_input_preview.pending_steers.is_empty(); + || !self.pending_input_preview.pending_steers.is_empty() + || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; @@ -1546,7 +1549,11 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); @@ -1573,7 +1580,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); pane.hide_status_indicator(); let width = 48; @@ -1601,7 +1612,11 @@ mod tests { }); pane.set_task_running(true); - pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); + pane.set_pending_input_preview( + vec!["Queued follow-up question".to_string()], + Vec::new(), + Vec::new(), + ); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 1f38a1777..b9690c28e 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -10,17 +10,19 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus user messages queued while a turn is in progress. +/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. /// -/// The widget renders pending steers first, then queued user messages, as two -/// labeled sections. Pending steers explain that they will be submitted after -/// the next tool/result boundary unless the user presses Esc to interrupt and -/// send them immediately. The edit hint at the bottom only appears when there -/// are actual queued user messages to pop back into the composer. Because some -/// terminals intercept certain modifier-key combinations, the displayed -/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget renders pending steers first, then rejected steers that will be +/// resubmitted at end of turn, then ordinary queued user messages. Pending +/// steers explain that they will be submitted after the next tool/result +/// boundary unless the user presses Esc to interrupt and send them +/// immediately. The edit hint at the bottom only appears when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { pub pending_steers: Vec, + pub rejected_steers: Vec, pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -33,6 +35,7 @@ impl PendingInputPreview { pub(crate) fn new() -> Self { Self { pending_steers: Vec::new(), + rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -67,7 +70,11 @@ impl PendingInputPreview { } fn as_renderable(&self, width: u16) -> Box { - if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { + if (self.pending_steers.is_empty() + && self.rejected_steers.is_empty() + && self.queued_messages.is_empty()) + || width < 4 + { return Box::new(()); } @@ -96,6 +103,27 @@ impl PendingInputPreview { } } + if !self.rejected_steers.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + Self::push_section_header( + &mut lines, + width, + "Messages to be submitted at end of turn".into(), + ); + + for steer in &self.rejected_steers { + let wrapped = adaptive_wrap_lines( + steer.lines().map(|line| Line::from(line.dim())), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim())); + } + } + if !self.queued_messages.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); @@ -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()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 0f0d1eabd..16da87fb9 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 52, height: 8 }, + area: Rect { x: 0, y: 0, width: 52, height: 11 }, content: [ "• Messages to be submitted after next tool call ", " (press esc to interrupt and send immediately) ", " ↳ Please continue. ", " ↳ Check the last command output. ", " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", "• Queued follow-up messages ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", @@ -26,9 +29,13 @@ Buffer { x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 78fcc84e5..ad8b9e709 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -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, + // User messages that tried to steer a non-regular turn and must be retried first. + rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -958,9 +960,11 @@ impl ThreadComposerState { pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, + rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, + task_running: bool, agent_turn_running: bool, } @@ -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 { + if self.rejected_steers_queue.is_empty() { + self.queued_user_messages.pop_front() + } else { + Some(merge_user_messages( + self.rejected_steers_queue.drain(..).collect(), + )) + } + } + + fn pop_latest_queued_user_message(&mut self) -> Option { + self.queued_user_messages + .pop_back() + .or_else(|| self.rejected_steers_queue.pop_back()) + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.rejected_steers_queue + .push_back(pending_steer.user_message); + self.refresh_pending_input_preview(); + true + } + + #[cfg(test)] + fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { + matches!( + codex_error_info, + CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2508,7 +2563,11 @@ impl ChatWidget { message: String, codex_error_info: Option, ) { - if let Some(info) = codex_error_info + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) { @@ -2648,7 +2707,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { + if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -2660,11 +2719,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self - .pending_steers - .drain(..) - .map(|steer| steer.user_message) - .collect(); + let mut to_merge: Vec = self.rejected_steers_queue.drain(..).collect(); + to_merge.extend( + self.pending_steers + .drain(..) + .map(|steer| steer.user_message), + ); to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() @@ -2710,14 +2770,17 @@ impl ChatWidget { .iter() .map(|pending| pending.user_message.clone()) .collect(), + rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; @@ -2749,13 +2812,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(), @@ -2769,6 +2843,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(); } @@ -4244,6 +4321,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, @@ -4381,9 +4459,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(); @@ -4607,6 +4685,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 => { @@ -5099,10 +5180,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 { @@ -5117,12 +5195,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, @@ -6406,7 +6478,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); @@ -6418,8 +6492,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 => { @@ -6784,7 +6863,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). @@ -6803,8 +6882,16 @@ impl ChatWidget { .iter() .map(|steer| steer.user_message.text.clone()) .collect(); - self.bottom_pane - .set_pending_input_preview(queued_messages, pending_steers); + let rejected_steers: Vec = self + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .collect(); + self.bottom_pane.set_pending_input_preview( + queued_messages, + pending_steers, + rejected_steers, + ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -10035,9 +10122,14 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.queued_user_messages + self.rejected_steers_queue .iter() .map(|message| message.text.clone()) + .chain( + self.queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) .collect() } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap new file mode 100644 index 000000000..65f784518 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + +• Working (0s • esc to interrupt) + +• Messages to be submitted at end of turn + ↳ Steer submitted while /compact was running. + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap index 3985a1dc2..ad7a7ed27 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents() + • Working (0s • esc to interrupt) -• Queued follow-up messages - ↳ Queued while /review is running. - ⌥ + ↑ edit last queued message +• Messages to be submitted at end of turn + ↳ Steer submitted while /review was running. › Ask Codex to do anything diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 31e94249a..e8173a43f 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -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;