mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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 <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
54801634e1
commit
e838645fa2
@@ -514,6 +514,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1623,6 +1645,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -5658,6 +5658,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/v2/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -9115,6 +9137,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
|
||||
@@ -2251,6 +2251,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5863,6 +5885,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
|
||||
@@ -112,9 +112,38 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TurnError": {
|
||||
"properties": {
|
||||
"additionalDetails": {
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -196,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -589,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -196,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -589,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -196,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -589,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -134,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -520,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
|
||||
/**
|
||||
* This translation layer make sure that we expose codex error code in camel case.
|
||||
@@ -8,4 +9,4 @@
|
||||
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
*/
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other";
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NonSteerableTurnKind = "review" | "compact";
|
||||
@@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
|
||||
export type { NetworkRequirements } from "./NetworkRequirements";
|
||||
export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
export type { OverriddenMetadata } from "./OverriddenMetadata";
|
||||
export type { PatchApplyStatus } from "./PatchApplyStatus";
|
||||
export type { PatchChangeKind } from "./PatchChangeKind";
|
||||
|
||||
@@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
|
||||
use codex_protocol::protocol::HookScope as CoreHookScope;
|
||||
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
|
||||
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
|
||||
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
@@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core {
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum NonSteerableTurnKind {
|
||||
Review,
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// This translation layer make sure that we expose codex error code in camel case.
|
||||
///
|
||||
/// When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
@@ -167,6 +176,13 @@ pub enum CodexErrorInfo {
|
||||
#[ts(rename = "httpStatusCode")]
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
/// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
|
||||
/// cannot accept same-turn steering, for example `/review` or manual `/compact`.
|
||||
ActiveTurnNotSteerable {
|
||||
#[serde(rename = "turnKind")]
|
||||
#[ts(rename = "turnKind")]
|
||||
turn_kind: NonSteerableTurnKind,
|
||||
},
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -193,11 +209,25 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => {
|
||||
CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code }
|
||||
}
|
||||
CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => {
|
||||
CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: turn_kind.into(),
|
||||
}
|
||||
}
|
||||
CoreCodexErrorInfo::Other => CodexErrorInfo::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreNonSteerableTurnKind> for NonSteerableTurnKind {
|
||||
fn from(value: CoreNonSteerableTurnKind) -> Self {
|
||||
match value {
|
||||
CoreNonSteerableTurnKind::Review => Self::Review,
|
||||
CoreNonSteerableTurnKind::Compact => Self::Compact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi,
|
||||
)]
|
||||
@@ -7832,6 +7862,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
|
||||
let value = CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(value).unwrap(),
|
||||
json!({
|
||||
"activeTurnNotSteerable": {
|
||||
"turnKind": "review"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_response_serializes_content_items() {
|
||||
let value = serde_json::to_value(DynamicToolCallResponse {
|
||||
|
||||
@@ -140,7 +140,7 @@ Example with notification opt-out:
|
||||
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input.
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications.
|
||||
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
|
||||
@@ -575,8 +575,8 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
|
||||
|
||||
### Example: Steer an active turn
|
||||
|
||||
Use `turn/steer` to append additional user input to the currently active turn. This does not emit
|
||||
`turn/started` and does not accept turn context overrides.
|
||||
Use `turn/steer` to append additional user input to the currently active regular turn. This does
|
||||
not emit `turn/started` and does not accept turn context overrides.
|
||||
|
||||
```json
|
||||
{ "method": "turn/steer", "id": 32, "params": {
|
||||
@@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T
|
||||
{ "id": 32, "result": { "turnId": "turn_456" } }
|
||||
```
|
||||
|
||||
`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error.
|
||||
`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the
|
||||
active turn, or the active turn kind does not accept same-turn steering (for example review or
|
||||
manual compaction), the request fails with an `invalid request` error.
|
||||
|
||||
### Example: Request a code review
|
||||
|
||||
@@ -918,6 +920,8 @@ There are additional item-specific events:
|
||||
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
|
||||
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
|
||||
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
|
||||
- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the
|
||||
current active turn was not steerable, for example `/review` or manual `/compact`
|
||||
- `BadRequest`
|
||||
- `Unauthorized`
|
||||
- `SandboxError`
|
||||
|
||||
@@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
@@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeStatus;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnInterruptParams;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
@@ -6131,24 +6133,57 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let (code, message) = match err {
|
||||
let (code, message, data) = match err {
|
||||
SteerInputError::NoActiveTurn(_) => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
"no active turn to steer".to_string(),
|
||||
None,
|
||||
),
|
||||
SteerInputError::ExpectedTurnMismatch { expected, actual } => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
format!("expected active turn id `{expected}` but found `{actual}`"),
|
||||
None,
|
||||
),
|
||||
SteerInputError::ActiveTurnNotSteerable { turn_kind } => {
|
||||
let message = match turn_kind {
|
||||
codex_protocol::protocol::NonSteerableTurnKind::Review => {
|
||||
"cannot steer a review turn".to_string()
|
||||
}
|
||||
codex_protocol::protocol::NonSteerableTurnKind::Compact => {
|
||||
"cannot steer a compact turn".to_string()
|
||||
}
|
||||
};
|
||||
let error = TurnError {
|
||||
message: message.clone(),
|
||||
codex_error_info: Some(
|
||||
AppServerCodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: turn_kind.into(),
|
||||
},
|
||||
),
|
||||
additional_details: None,
|
||||
};
|
||||
let data = match serde_json::to_value(error) {
|
||||
Ok(data) => Some(data),
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
?error,
|
||||
"failed to serialize active-turn-not-steerable turn error"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
(INVALID_REQUEST_ERROR_CODE, message, data)
|
||||
}
|
||||
SteerInputError::EmptyInput => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
"input must not be empty".to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let error = JSONRPCErrorError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
data,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
|
||||
+70
-13
@@ -186,9 +186,41 @@ mod rollout_reconstruction_tests;
|
||||
pub enum SteerInputError {
|
||||
NoActiveTurn(Vec<UserInput>),
|
||||
ExpectedTurnMismatch { expected: String, actual: String },
|
||||
ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
|
||||
EmptyInput,
|
||||
}
|
||||
|
||||
impl SteerInputError {
|
||||
fn to_error_event(&self) -> ErrorEvent {
|
||||
match self {
|
||||
Self::NoActiveTurn(_) => ErrorEvent {
|
||||
message: "no active turn to steer".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent {
|
||||
message: format!("expected active turn id `{expected}` but found `{actual}`"),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ActiveTurnNotSteerable { turn_kind } => {
|
||||
let turn_kind_label = match turn_kind {
|
||||
NonSteerableTurnKind::Review => "review",
|
||||
NonSteerableTurnKind::Compact => "compact",
|
||||
};
|
||||
ErrorEvent {
|
||||
message: format!("cannot steer a {turn_kind_label} turn"),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: *turn_kind,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Self::EmptyInput => ErrorEvent {
|
||||
message: "input must not be empty".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes from the previous real user turn.
|
||||
///
|
||||
/// Conceptually this is the same role that `previous_model` used to fill, but
|
||||
@@ -333,6 +365,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
@@ -3859,6 +3892,21 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
match active_turn.tasks.first().map(|(_, task)| task.kind) {
|
||||
Some(crate::state::TaskKind::Regular) => {}
|
||||
Some(crate::state::TaskKind::Review) => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
});
|
||||
}
|
||||
Some(crate::state::TaskKind::Compact) => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
});
|
||||
}
|
||||
None => return Err(SteerInputError::NoActiveTurn(input)),
|
||||
}
|
||||
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.push_pending_input(input.into());
|
||||
Ok(active_turn_id.clone())
|
||||
@@ -4526,26 +4574,35 @@ mod handlers {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
};
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
|
||||
// Attempt to inject input into current task.
|
||||
if let Err(SteerInputError::NoActiveTurn(items)) =
|
||||
sess.steer_input(items, /*expected_turn_id*/ None).await
|
||||
match sess
|
||||
.steer_input(items.clone(), /*expected_turn_id*/ None)
|
||||
.await
|
||||
{
|
||||
sess.refresh_mcp_servers_if_requested(¤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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1538,6 +1538,15 @@ pub enum AgentStatus {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Turn kinds that reject same-turn steering.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum NonSteerableTurnKind {
|
||||
Review,
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// Codex errors that we expose to clients.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1565,6 +1574,11 @@ pub enum CodexErrorInfo {
|
||||
ResponseTooManyFailedAttempts {
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
/// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
|
||||
/// cannot accept same-turn steering, for example `/review` or manual `/compact`.
|
||||
ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind,
|
||||
},
|
||||
ThreadRollbackFailed,
|
||||
Other,
|
||||
}
|
||||
@@ -1573,7 +1587,7 @@ impl CodexErrorInfo {
|
||||
/// Whether this error should mark the current turn as failed when replaying history.
|
||||
pub fn affects_turn_status(&self) -> bool {
|
||||
match self {
|
||||
Self::ThreadRollbackFailed => false,
|
||||
Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false,
|
||||
Self::ContextWindowExceeded
|
||||
| Self::UsageLimitExceeded
|
||||
| Self::ServerOverloaded
|
||||
@@ -4211,6 +4225,17 @@ mod tests {
|
||||
assert!(!event.affects_turn_status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_not_steerable_error_does_not_affect_turn_status() {
|
||||
let event = ErrorEvent {
|
||||
message: "cannot steer a review turn".into(),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
}),
|
||||
};
|
||||
assert!(!event.affects_turn_status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_error_affects_turn_status() {
|
||||
let event = ErrorEvent {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -825,8 +825,10 @@ impl BottomPane {
|
||||
&mut self,
|
||||
queued: Vec<String>,
|
||||
pending_steers: Vec<String>,
|
||||
rejected_steers: Vec<String>,
|
||||
) {
|
||||
self.pending_input_preview.pending_steers = pending_steers;
|
||||
self.pending_input_preview.rejected_steers = rejected_steers;
|
||||
self.pending_input_preview.queued_messages = queued;
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -1153,7 +1155,8 @@ impl BottomPane {
|
||||
}
|
||||
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
|
||||
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|
||||
|| !self.pending_input_preview.pending_steers.is_empty();
|
||||
|| !self.pending_input_preview.pending_steers.is_empty()
|
||||
|| !self.pending_input_preview.rejected_steers.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
|
||||
@@ -1556,7 +1559,11 @@ mod tests {
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
@@ -1583,7 +1590,11 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
pane.hide_status_indicator();
|
||||
|
||||
let width = 48;
|
||||
@@ -1611,7 +1622,11 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
|
||||
@@ -10,17 +10,19 @@ use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
|
||||
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
|
||||
/// Widget that displays pending steers plus follow-up messages held while a turn is in progress.
|
||||
///
|
||||
/// The widget renders pending steers first, then queued user messages, as two
|
||||
/// labeled sections. Pending steers explain that they will be submitted after
|
||||
/// the next tool/result boundary unless the user presses Esc to interrupt and
|
||||
/// send them immediately. The edit hint at the bottom only appears when there
|
||||
/// are actual queued user messages to pop back into the composer. Because some
|
||||
/// terminals intercept certain modifier-key combinations, the displayed
|
||||
/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
/// The widget renders pending steers first, then rejected steers that will be
|
||||
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
||||
/// steers explain that they will be submitted after the next tool/result
|
||||
/// boundary unless the user presses Esc to interrupt and send them
|
||||
/// immediately. The edit hint at the bottom only appears when there are actual
|
||||
/// queued user messages to pop back into the composer. Because some terminals
|
||||
/// intercept certain modifier-key combinations, the displayed binding is
|
||||
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
pub(crate) struct PendingInputPreview {
|
||||
pub pending_steers: Vec<String>,
|
||||
pub rejected_steers: Vec<String>,
|
||||
pub queued_messages: Vec<String>,
|
||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||
/// be overridden for terminals where that chord is unavailable.
|
||||
@@ -33,6 +35,7 @@ impl PendingInputPreview {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
pending_steers: Vec::new(),
|
||||
rejected_steers: Vec::new(),
|
||||
queued_messages: Vec::new(),
|
||||
edit_binding: key_hint::alt(KeyCode::Up),
|
||||
}
|
||||
@@ -67,7 +70,11 @@ impl PendingInputPreview {
|
||||
}
|
||||
|
||||
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
||||
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
|
||||
if (self.pending_steers.is_empty()
|
||||
&& self.rejected_steers.is_empty()
|
||||
&& self.queued_messages.is_empty())
|
||||
|| width < 4
|
||||
{
|
||||
return Box::new(());
|
||||
}
|
||||
|
||||
@@ -96,6 +103,27 @@ impl PendingInputPreview {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.rejected_steers.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
Self::push_section_header(
|
||||
&mut lines,
|
||||
width,
|
||||
"Messages to be submitted at end of turn".into(),
|
||||
);
|
||||
|
||||
for steer in &self.rejected_steers {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
steer.lines().map(|line| Line::from(line.dim())),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(" ↳ ".dim()))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
);
|
||||
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.queued_messages.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
@@ -304,6 +332,9 @@ mod tests {
|
||||
queue
|
||||
.pending_steers
|
||||
.push("Check the last command output.".to_string());
|
||||
queue
|
||||
.rejected_steers
|
||||
.push("Rejected steer that will be retried.".to_string());
|
||||
queue
|
||||
.queued_messages
|
||||
.push("Queued follow-up question".to_string());
|
||||
|
||||
+12
-5
@@ -3,13 +3,16 @@ source: tui/src/bottom_pane/pending_input_preview.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 52, height: 8 },
|
||||
area: Rect { x: 0, y: 0, width: 52, height: 11 },
|
||||
content: [
|
||||
"• Messages to be submitted after next tool call ",
|
||||
" (press esc to interrupt and send immediately) ",
|
||||
" ↳ Please continue. ",
|
||||
" ↳ Check the last command output. ",
|
||||
" ",
|
||||
"• Messages to be submitted at end of turn ",
|
||||
" ↳ Rejected steer that will be retried. ",
|
||||
" ",
|
||||
"• Queued follow-up messages ",
|
||||
" ↳ Queued follow-up question ",
|
||||
" ⌥ + ↑ edit last queued message ",
|
||||
@@ -26,9 +29,13 @@ Buffer {
|
||||
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
+113
-31
@@ -765,6 +765,8 @@ pub(crate) struct ChatWidget {
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// User messages that tried to steer a non-regular turn and must be retried first.
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
// Steers already submitted to core but not yet committed into history.
|
||||
//
|
||||
// The bottom pane shows these above queued drafts until core records the
|
||||
@@ -925,9 +927,11 @@ impl ThreadComposerState {
|
||||
pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
pending_steers: VecDeque<UserMessage>,
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
current_collaboration_mode: CollaborationMode,
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
task_running: bool,
|
||||
agent_turn_running: bool,
|
||||
}
|
||||
|
||||
@@ -1810,7 +1814,7 @@ impl ChatWidget {
|
||||
let had_pending_steers = !self.pending_steers.is_empty();
|
||||
self.refresh_pending_input_preview();
|
||||
|
||||
if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers {
|
||||
if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers {
|
||||
self.maybe_prompt_plan_implementation();
|
||||
}
|
||||
// Keep this flag for replayed completion events so a subsequent live TurnComplete can
|
||||
@@ -1832,7 +1836,7 @@ impl ChatWidget {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
if self.has_queued_follow_up_messages() {
|
||||
return;
|
||||
}
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
@@ -1904,6 +1908,50 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
fn has_queued_follow_up_messages(&self) -> bool {
|
||||
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
|
||||
}
|
||||
|
||||
fn pop_next_queued_user_message(&mut self) -> Option<UserMessage> {
|
||||
if self.rejected_steers_queue.is_empty() {
|
||||
self.queued_user_messages.pop_front()
|
||||
} else {
|
||||
Some(merge_user_messages(
|
||||
self.rejected_steers_queue.drain(..).collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_latest_queued_user_message(&mut self) -> Option<UserMessage> {
|
||||
self.queued_user_messages
|
||||
.pop_back()
|
||||
.or_else(|| self.rejected_steers_queue.pop_back())
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_rejected_steer(&mut self) -> bool {
|
||||
let Some(pending_steer) = self.pending_steers.pop_front() else {
|
||||
tracing::warn!(
|
||||
"received active-turn-not-steerable error without a matching pending steer"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
self.rejected_steers_queue
|
||||
.push_back(pending_steer.user_message);
|
||||
if !self.bottom_pane.is_task_running() {
|
||||
// Will drain rejected_steers_queue in case the steer rejection arrives after task completion
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
self.refresh_pending_input_preview();
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_steer_rejected_error(&mut self, codex_error_info: &CodexErrorInfo) -> bool {
|
||||
matches!(
|
||||
codex_error_info,
|
||||
CodexErrorInfo::ActiveTurnNotSteerable { .. }
|
||||
) && self.enqueue_rejected_steer()
|
||||
}
|
||||
|
||||
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
@@ -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<UserMessage> {
|
||||
if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() {
|
||||
if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -2280,11 +2328,12 @@ impl ChatWidget {
|
||||
mention_bindings: self.bottom_pane.composer_mention_bindings(),
|
||||
};
|
||||
|
||||
let mut to_merge: Vec<UserMessage> = self
|
||||
.pending_steers
|
||||
.drain(..)
|
||||
.map(|steer| steer.user_message)
|
||||
.collect();
|
||||
let mut to_merge: Vec<UserMessage> = self.rejected_steers_queue.drain(..).collect();
|
||||
to_merge.extend(
|
||||
self.pending_steers
|
||||
.drain(..)
|
||||
.map(|steer| steer.user_message),
|
||||
);
|
||||
to_merge.extend(self.queued_user_messages.drain(..));
|
||||
if !existing_message.text.is_empty()
|
||||
|| !existing_message.local_images.is_empty()
|
||||
@@ -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<ThreadInputState>) {
|
||||
let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running);
|
||||
if let Some(input_state) = input_state {
|
||||
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
||||
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
||||
@@ -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<String> = self
|
||||
.rejected_steers_queue
|
||||
.iter()
|
||||
.map(|message| message.text.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_pending_input_preview(
|
||||
queued_messages,
|
||||
pending_steers,
|
||||
rejected_steers,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
|
||||
@@ -8802,9 +8879,14 @@ impl ChatWidget {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
|
||||
self.queued_user_messages
|
||||
self.rejected_steers_queue
|
||||
.iter()
|
||||
.map(|message| message.text.clone())
|
||||
.chain(
|
||||
self.queued_user_messages
|
||||
.iter()
|
||||
.map(|message| message.text.clone()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppServerTurnError> {
|
||||
let TypedRequestError::Server { source, .. } = error else {
|
||||
return None;
|
||||
};
|
||||
let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?;
|
||||
matches!(
|
||||
turn_error.codex_error_info,
|
||||
Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. })
|
||||
)
|
||||
.then_some(turn_error)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
@@ -1950,9 +1965,21 @@ impl App {
|
||||
personality,
|
||||
} => {
|
||||
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
||||
app_server
|
||||
match app_server
|
||||
.turn_steer(thread_id, turn_id, items.to_vec())
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) {
|
||||
if !self.chat_widget.enqueue_rejected_steer() {
|
||||
self.chat_widget.add_error_message(turn_error.message);
|
||||
}
|
||||
} else {
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app_server
|
||||
.turn_start(
|
||||
@@ -5170,10 +5197,12 @@ mod tests {
|
||||
use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
|
||||
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
|
||||
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
|
||||
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
@@ -5186,6 +5215,7 @@ mod tests {
|
||||
use codex_app_server_protocol::TokenUsageBreakdown;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||||
@@ -5735,7 +5765,7 @@ mod tests {
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
@@ -5785,7 +5815,7 @@ mod tests {
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
@@ -5834,7 +5864,7 @@ mod tests {
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
@@ -5881,7 +5911,7 @@ mod tests {
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
@@ -5951,7 +5981,7 @@ mod tests {
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
@@ -8017,6 +8047,30 @@ guardian_approval = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
|
||||
let turn_error = AppServerTurnError {
|
||||
message: "cannot steer a review turn".to_string(),
|
||||
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: AppServerNonSteerableTurnKind::Review,
|
||||
}),
|
||||
additional_details: None,
|
||||
};
|
||||
let error = TypedRequestError::Server {
|
||||
method: "turn/steer".to_string(),
|
||||
source: JSONRPCErrorError {
|
||||
code: -32602,
|
||||
message: turn_error.message.clone(),
|
||||
data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
active_turn_not_steerable_turn_error(&error),
|
||||
Some(turn_error)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
|
||||
let mut presets = all_model_presets();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use codex_app_server_client::AppServerClient;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_client::TypedRequestError;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
@@ -429,7 +430,7 @@ impl AppServerSession {
|
||||
thread_id: ThreadId,
|
||||
turn_id: String,
|
||||
items: Vec<codex_protocol::user_input::UserInput>,
|
||||
) -> Result<TurnSteerResponse> {
|
||||
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::TurnSteer {
|
||||
@@ -441,7 +442,6 @@ impl AppServerSession {
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("turn/steer failed in app-server TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_set_name(
|
||||
|
||||
@@ -821,8 +821,10 @@ impl BottomPane {
|
||||
&mut self,
|
||||
queued: Vec<String>,
|
||||
pending_steers: Vec<String>,
|
||||
rejected_steers: Vec<String>,
|
||||
) {
|
||||
self.pending_input_preview.pending_steers = pending_steers;
|
||||
self.pending_input_preview.rejected_steers = rejected_steers;
|
||||
self.pending_input_preview.queued_messages = queued;
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -1143,7 +1145,8 @@ impl BottomPane {
|
||||
}
|
||||
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
|
||||
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|
||||
|| !self.pending_input_preview.pending_steers.is_empty();
|
||||
|| !self.pending_input_preview.pending_steers.is_empty()
|
||||
|| !self.pending_input_preview.rejected_steers.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
|
||||
@@ -1546,7 +1549,11 @@ mod tests {
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
@@ -1573,7 +1580,11 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
pane.hide_status_indicator();
|
||||
|
||||
let width = 48;
|
||||
@@ -1601,7 +1612,11 @@ mod tests {
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
|
||||
pane.set_pending_input_preview(
|
||||
vec!["Queued follow-up question".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
|
||||
@@ -10,17 +10,19 @@ use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
|
||||
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
|
||||
/// Widget that displays pending steers plus follow-up messages held while a turn is in progress.
|
||||
///
|
||||
/// The widget renders pending steers first, then queued user messages, as two
|
||||
/// labeled sections. Pending steers explain that they will be submitted after
|
||||
/// the next tool/result boundary unless the user presses Esc to interrupt and
|
||||
/// send them immediately. The edit hint at the bottom only appears when there
|
||||
/// are actual queued user messages to pop back into the composer. Because some
|
||||
/// terminals intercept certain modifier-key combinations, the displayed
|
||||
/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
/// The widget renders pending steers first, then rejected steers that will be
|
||||
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
||||
/// steers explain that they will be submitted after the next tool/result
|
||||
/// boundary unless the user presses Esc to interrupt and send them
|
||||
/// immediately. The edit hint at the bottom only appears when there are actual
|
||||
/// queued user messages to pop back into the composer. Because some terminals
|
||||
/// intercept certain modifier-key combinations, the displayed binding is
|
||||
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
|
||||
pub(crate) struct PendingInputPreview {
|
||||
pub pending_steers: Vec<String>,
|
||||
pub rejected_steers: Vec<String>,
|
||||
pub queued_messages: Vec<String>,
|
||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||
/// be overridden for terminals where that chord is unavailable.
|
||||
@@ -33,6 +35,7 @@ impl PendingInputPreview {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
pending_steers: Vec::new(),
|
||||
rejected_steers: Vec::new(),
|
||||
queued_messages: Vec::new(),
|
||||
edit_binding: key_hint::alt(KeyCode::Up),
|
||||
}
|
||||
@@ -67,7 +70,11 @@ impl PendingInputPreview {
|
||||
}
|
||||
|
||||
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
||||
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
|
||||
if (self.pending_steers.is_empty()
|
||||
&& self.rejected_steers.is_empty()
|
||||
&& self.queued_messages.is_empty())
|
||||
|| width < 4
|
||||
{
|
||||
return Box::new(());
|
||||
}
|
||||
|
||||
@@ -96,6 +103,27 @@ impl PendingInputPreview {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.rejected_steers.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
Self::push_section_header(
|
||||
&mut lines,
|
||||
width,
|
||||
"Messages to be submitted at end of turn".into(),
|
||||
);
|
||||
|
||||
for steer in &self.rejected_steers {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
steer.lines().map(|line| Line::from(line.dim())),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(" ↳ ".dim()))
|
||||
.subsequent_indent(Line::from(" ")),
|
||||
);
|
||||
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.queued_messages.is_empty() {
|
||||
if !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
@@ -304,6 +332,9 @@ mod tests {
|
||||
queue
|
||||
.pending_steers
|
||||
.push("Check the last command output.".to_string());
|
||||
queue
|
||||
.rejected_steers
|
||||
.push("Rejected steer that will be retried.".to_string());
|
||||
queue
|
||||
.queued_messages
|
||||
.push("Queued follow-up question".to_string());
|
||||
|
||||
+12
-5
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -807,6 +807,8 @@ pub(crate) struct ChatWidget {
|
||||
suppress_initial_user_message_submit: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// User messages that tried to steer a non-regular turn and must be retried first.
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
// Steers already submitted to core but not yet committed into history.
|
||||
//
|
||||
// The bottom pane shows these above queued drafts until core records the
|
||||
@@ -958,9 +960,11 @@ impl ThreadComposerState {
|
||||
pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
pending_steers: VecDeque<UserMessage>,
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
current_collaboration_mode: CollaborationMode,
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
task_running: bool,
|
||||
agent_turn_running: bool,
|
||||
}
|
||||
|
||||
@@ -2172,7 +2176,7 @@ impl ChatWidget {
|
||||
let had_pending_steers = !self.pending_steers.is_empty();
|
||||
self.refresh_pending_input_preview();
|
||||
|
||||
if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers {
|
||||
if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers {
|
||||
self.maybe_prompt_plan_implementation();
|
||||
}
|
||||
// Keep this flag for replayed completion events so a subsequent live TurnComplete can
|
||||
@@ -2194,7 +2198,7 @@ impl ChatWidget {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
if self.has_queued_follow_up_messages() {
|
||||
return;
|
||||
}
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
@@ -2266,6 +2270,57 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
fn has_queued_follow_up_messages(&self) -> bool {
|
||||
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
|
||||
}
|
||||
|
||||
fn pop_next_queued_user_message(&mut self) -> Option<UserMessage> {
|
||||
if self.rejected_steers_queue.is_empty() {
|
||||
self.queued_user_messages.pop_front()
|
||||
} else {
|
||||
Some(merge_user_messages(
|
||||
self.rejected_steers_queue.drain(..).collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_latest_queued_user_message(&mut self) -> Option<UserMessage> {
|
||||
self.queued_user_messages
|
||||
.pop_back()
|
||||
.or_else(|| self.rejected_steers_queue.pop_back())
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_rejected_steer(&mut self) -> bool {
|
||||
let Some(pending_steer) = self.pending_steers.pop_front() else {
|
||||
tracing::warn!(
|
||||
"received active-turn-not-steerable error without a matching pending steer"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
self.rejected_steers_queue
|
||||
.push_back(pending_steer.user_message);
|
||||
self.refresh_pending_input_preview();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool {
|
||||
matches!(
|
||||
codex_error_info,
|
||||
CoreCodexErrorInfo::ActiveTurnNotSteerable { .. }
|
||||
) && self.enqueue_rejected_steer()
|
||||
}
|
||||
|
||||
fn handle_app_server_steer_rejected_error(
|
||||
&mut self,
|
||||
codex_error_info: &AppServerCodexErrorInfo,
|
||||
) -> bool {
|
||||
matches!(
|
||||
codex_error_info,
|
||||
AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }
|
||||
) && self.enqueue_rejected_steer()
|
||||
}
|
||||
|
||||
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
@@ -2508,7 +2563,11 @@ impl ChatWidget {
|
||||
message: String,
|
||||
codex_error_info: Option<AppServerCodexErrorInfo>,
|
||||
) {
|
||||
if let Some(info) = codex_error_info
|
||||
if codex_error_info
|
||||
.as_ref()
|
||||
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
|
||||
{
|
||||
} else if let Some(info) = codex_error_info
|
||||
.as_ref()
|
||||
.and_then(app_server_rate_limit_error_kind)
|
||||
{
|
||||
@@ -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<UserMessage> {
|
||||
if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() {
|
||||
if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -2660,11 +2719,12 @@ impl ChatWidget {
|
||||
mention_bindings: self.bottom_pane.composer_mention_bindings(),
|
||||
};
|
||||
|
||||
let mut to_merge: Vec<UserMessage> = self
|
||||
.pending_steers
|
||||
.drain(..)
|
||||
.map(|steer| steer.user_message)
|
||||
.collect();
|
||||
let mut to_merge: Vec<UserMessage> = self.rejected_steers_queue.drain(..).collect();
|
||||
to_merge.extend(
|
||||
self.pending_steers
|
||||
.drain(..)
|
||||
.map(|steer| steer.user_message),
|
||||
);
|
||||
to_merge.extend(self.queued_user_messages.drain(..));
|
||||
if !existing_message.text.is_empty()
|
||||
|| !existing_message.local_images.is_empty()
|
||||
@@ -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<ThreadInputState>) {
|
||||
let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running);
|
||||
if let Some(input_state) = input_state {
|
||||
self.current_collaboration_mode = input_state.current_collaboration_mode;
|
||||
self.active_collaboration_mask = input_state.active_collaboration_mask;
|
||||
@@ -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<String> = self
|
||||
.rejected_steers_queue
|
||||
.iter()
|
||||
.map(|message| message.text.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_pending_input_preview(
|
||||
queued_messages,
|
||||
pending_steers,
|
||||
rejected_steers,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
|
||||
@@ -10035,9 +10122,14 @@ impl ChatWidget {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
|
||||
self.queued_user_messages
|
||||
self.rejected_steers_queue
|
||||
.iter()
|
||||
.map(|message| message.text.clone())
|
||||
.chain(
|
||||
self.queued_user_messages
|
||||
.iter()
|
||||
.map(|message| message.text.clone()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user