merge(modes): integrate main into subagent activity
48 files · +1717 -256
M docs/rpc.md +12 -2
| @@ -192,10 +192,16 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 192 | 192 | | `type` | Fields | Meaning | | |
| 193 | 193 | |---|---|---| | |
| 194 | 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | |
| 195 | + | | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started | | |
| 196 | + | | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer | | |
| 195 | 197 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | |
| 196 | - | | `assistant_start` | (none) | About to receive assistant streaming | | |
| 198 | + | | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming | | |
| 197 | 199 | | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply | | |
| 198 | - | | `tool_call` | `id`, `name`, `args` | The model wants to call a tool | | |
| 200 | + | | `tool_use_start` | `id`, `name` | Provider started streaming a tool call | | |
| 201 | + | | `tool_use_args` | `id`, `delta` | Partial tool-call argument JSON | | |
| 202 | + | | `tool_use_end` | `id` | Provider completed the tool-call argument stream | | |
| 203 | + | | `tool_call` | `id`, `name`, `args` | The model proposed a tool call | | |
| 204 | + | | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute | | |
| 199 | 205 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | |
| 200 | 206 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |
| 201 | 207 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |
| @@ -209,6 +215,10 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 209 | 215 | | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. | | |
| 210 | 216 | | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. | | |
| 211 | 217 | ||
| 218 | + | A `compact` request emits request-lifecycle, `assistant_start`, and summary | |
| 219 | + | `text_delta` events before `compact_done`. It does not emit model-loop turn or | |
| 220 | + | tool events. | |
| 221 | + | ||
| 212 | 222 | ## Message shape | |
| 213 | 223 | ||
| 214 | 224 | Used by `get_messages` and inside `user_message` / `assistant_message` events. | |
| @@ -192,10 +192,16 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 192 | | `type` | Fields | Meaning | | 192 | | `type` | Fields | Meaning | | ||
| 193 | |---|---|---| | 193 | |---|---|---| | ||
| 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | 194 | | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) | | ||
| 195 | + | | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started | | |||
| 196 | + | | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer | | |||
| 195 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | 197 | | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript | | ||
| 196 | - | | `assistant_start` | (none) | About to receive assistant streaming | | 198 | + | | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming | |
| 197 | | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply | | 199 | | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply | | ||
| 198 | - | | `tool_call` | `id`, `name`, `args` | The model wants to call a tool | | 200 | + | | `tool_use_start` | `id`, `name` | Provider started streaming a tool call | |
| 201 | + | | `tool_use_args` | `id`, `delta` | Partial tool-call argument JSON | | |||
| 202 | + | | `tool_use_end` | `id` | Provider completed the tool-call argument stream | | |||
| 203 | + | | `tool_call` | `id`, `name`, `args` | The model proposed a tool call | | |||
| 204 | + | | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute | | |||
| 199 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | 205 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | ||
| 200 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | 206 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | ||
| 201 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | 207 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | ||
| @@ -209,6 +215,10 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 209 | | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. | | 215 | | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. | | ||
| 210 | | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. | | 216 | | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. | | ||
| 211 | 217 | ||||
| 218 | + | A `compact` request emits request-lifecycle, `assistant_start`, and summary | |||
| 219 | + | `text_delta` events before `compact_done`. It does not emit model-loop turn or | |||
| 220 | + | tool events. | |||
| 221 | + | ||||
| 212 | ## Message shape | 222 | ## Message shape | ||
| 213 | 223 | ||||
| 214 | Used by `get_messages` and inside `user_message` / `assistant_message` events. | 224 | Used by `get_messages` and inside `user_message` / `assistant_message` events. | ||
M docs/subagents.md +2 -0
| @@ -96,6 +96,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa | |||
| 96 | 96 | ||
| 97 | 97 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | |
| 98 | 98 | ||
| 99 | + | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | |
| 100 | + | ||
| 99 | 101 | ```text | |
| 100 | 102 | subagent://<id> | |
| 101 | 103 | subagent://<id>/history | |
| @@ -96,6 +96,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa | |||||
| 96 | 96 | ||||
| 97 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | 97 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | ||
| 98 | 98 | ||||
| 99 | + | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | |||
| 100 | + | ||||
| 99 | ```text | 101 | ```text | ||
| 100 | subagent://<id> | 102 | subagent://<id> | ||
| 101 | subagent://<id>/history | 103 | subagent://<id>/history | ||
M docs/themes.md +8 -6
| @@ -105,7 +105,6 @@ Spinner-only: | |||
| 105 | 105 | "name": "custom-spinner", | |
| 106 | 106 | "description": "Only changes the busy spinner.", | |
| 107 | 107 | "spinner_frames": ["◢", "◣", "◤", "◥"], | |
| 108 | - | "spinner_messages": ["working"], | |
| 109 | 108 | "spinner_interval_ms": 120 | |
| 110 | 109 | } | |
| 111 | 110 | ``` | |
| @@ -117,11 +116,10 @@ light default. The inverse also works. | |||
| 117 | 116 | ```json | |
| 118 | 117 | { | |
| 119 | 118 | "name": "custom-spinner", | |
| 120 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | |
| 119 | + | "description": "An alternative busy-spinner animation for zut.", | |
| 121 | 120 | "colors": { | |
| 122 | 121 | "dark": { | |
| 123 | 122 | "spinner_frames": ["◢", "◣", "◤", "◥"], | |
| 124 | - | "spinner_messages": ["working"], | |
| 125 | 123 | "spinner_interval_ms": 120 | |
| 126 | 124 | } | |
| 127 | 125 | } | |
| @@ -157,7 +155,6 @@ All fields are optional. | |||
| 157 | 155 | "selection_bg": 24, | |
| 158 | 156 | "selection_fg": 231, | |
| 159 | 157 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | |
| 160 | - | "spinner_messages": ["thinking", "working"], | |
| 161 | 158 | "spinner_interval_ms": 80, | |
| 162 | 159 | "syntax_base_style": "monokai", | |
| 163 | 160 | "syntax": { | |
| @@ -198,7 +195,7 @@ You may also put overrides directly at the top level or directly under | |||
| 198 | 195 | "name": "tiny", | |
| 199 | 196 | "accent": 204, | |
| 200 | 197 | "colors": { | |
| 201 | - | "spinner_messages": ["shipping"] | |
| 198 | + | "spinner_frames": ["◐", "◓", "◑", "◒"] | |
| 202 | 199 | } | |
| 203 | 200 | } | |
| 204 | 201 | ``` | |
| @@ -249,10 +246,15 @@ Spinner settings can appear at top level, under `colors`, or under | |||
| 249 | 246 | ||
| 250 | 247 | - `spinner_frames` — list of frame strings. Single-cell glyphs keep | |
| 251 | 248 | status-bar alignment clean. | |
| 252 | - | - `spinner_messages` — list of messages; zut picks one per turn. | |
| 253 | 249 | - `spinner_interval_ms` — frame interval in milliseconds. Missing or | |
| 254 | 250 | invalid falls back to 80ms. | |
| 255 | 251 | ||
| 252 | + | Working-status text is owned by zut and reflects the current operation. The | |
| 253 | + | legacy `spinner_messages` field is ignored when present in existing theme | |
| 254 | + | files. In this v0 API, the former `tui.Theme.SpinnerMessages` and | |
| 255 | + | `tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility | |
| 256 | + | alias is provided. | |
| 257 | + | ||
| 256 | 258 | ## Syntax fields | |
| 257 | 259 | ||
| 258 | 260 | Syntax highlighting uses Chroma style entries. Values may include | |
| @@ -105,7 +105,6 @@ Spinner-only: | |||||
| 105 | "name": "custom-spinner", | 105 | "name": "custom-spinner", | ||
| 106 | "description": "Only changes the busy spinner.", | 106 | "description": "Only changes the busy spinner.", | ||
| 107 | "spinner_frames": ["◢", "◣", "◤", "◥"], | 107 | "spinner_frames": ["◢", "◣", "◤", "◥"], | ||
| 108 | - | "spinner_messages": ["working"], | |||
| 109 | "spinner_interval_ms": 120 | 108 | "spinner_interval_ms": 120 | ||
| 110 | } | 109 | } | ||
| 111 | ``` | 110 | ``` | ||
| @@ -117,11 +116,10 @@ light default. The inverse also works. | |||||
| 117 | ```json | 116 | ```json | ||
| 118 | { | 117 | { | ||
| 119 | "name": "custom-spinner", | 118 | "name": "custom-spinner", | ||
| 120 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | 119 | + | "description": "An alternative busy-spinner animation for zut.", |
| 121 | "colors": { | 120 | "colors": { | ||
| 122 | "dark": { | 121 | "dark": { | ||
| 123 | "spinner_frames": ["◢", "◣", "◤", "◥"], | 122 | "spinner_frames": ["◢", "◣", "◤", "◥"], | ||
| 124 | - | "spinner_messages": ["working"], | |||
| 125 | "spinner_interval_ms": 120 | 123 | "spinner_interval_ms": 120 | ||
| 126 | } | 124 | } | ||
| 127 | } | 125 | } | ||
| @@ -157,7 +155,6 @@ All fields are optional. | |||||
| 157 | "selection_bg": 24, | 155 | "selection_bg": 24, | ||
| 158 | "selection_fg": 231, | 156 | "selection_fg": 231, | ||
| 159 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | 157 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | ||
| 160 | - | "spinner_messages": ["thinking", "working"], | |||
| 161 | "spinner_interval_ms": 80, | 158 | "spinner_interval_ms": 80, | ||
| 162 | "syntax_base_style": "monokai", | 159 | "syntax_base_style": "monokai", | ||
| 163 | "syntax": { | 160 | "syntax": { | ||
| @@ -198,7 +195,7 @@ You may also put overrides directly at the top level or directly under | |||||
| 198 | "name": "tiny", | 195 | "name": "tiny", | ||
| 199 | "accent": 204, | 196 | "accent": 204, | ||
| 200 | "colors": { | 197 | "colors": { | ||
| 201 | - | "spinner_messages": ["shipping"] | 198 | + | "spinner_frames": ["◐", "◓", "◑", "◒"] |
| 202 | } | 199 | } | ||
| 203 | } | 200 | } | ||
| 204 | ``` | 201 | ``` | ||
| @@ -249,10 +246,15 @@ Spinner settings can appear at top level, under `colors`, or under | |||||
| 249 | 246 | ||||
| 250 | - `spinner_frames` — list of frame strings. Single-cell glyphs keep | 247 | - `spinner_frames` — list of frame strings. Single-cell glyphs keep | ||
| 251 | status-bar alignment clean. | 248 | status-bar alignment clean. | ||
| 252 | - | - `spinner_messages` — list of messages; zut picks one per turn. | |||
| 253 | - `spinner_interval_ms` — frame interval in milliseconds. Missing or | 249 | - `spinner_interval_ms` — frame interval in milliseconds. Missing or | ||
| 254 | invalid falls back to 80ms. | 250 | invalid falls back to 80ms. | ||
| 255 | 251 | ||||
| 252 | + | Working-status text is owned by zut and reflects the current operation. The | |||
| 253 | + | legacy `spinner_messages` field is ignored when present in existing theme | |||
| 254 | + | files. In this v0 API, the former `tui.Theme.SpinnerMessages` and | |||
| 255 | + | `tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility | |||
| 256 | + | alias is provided. | |||
| 257 | + | ||||
| 256 | ## Syntax fields | 258 | ## Syntax fields | ||
| 257 | 259 | ||||
| 258 | Syntax highlighting uses Chroma style entries. Values may include | 260 | Syntax highlighting uses Chroma style entries. Values may include | ||
M examples/themes/custom-spinner.json +1 -4
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "custom-spinner", | |
| 3 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | |
| 3 | + | "description": "An alternative busy-spinner animation for zut.", | |
| 4 | 4 | "colors": { | |
| 5 | 5 | "dark": { | |
| 6 | 6 | "spinner_frames": [ | |
| @@ -13,9 +13,6 @@ | |||
| 13 | 13 | "\u28EF", | |
| 14 | 14 | "\u28F7" | |
| 15 | 15 | ], | |
| 16 | - | "spinner_messages": [ | |
| 17 | - | "working" | |
| 18 | - | ], | |
| 19 | 16 | "spinner_interval_ms": 120 | |
| 20 | 17 | } | |
| 21 | 18 | } | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | "name": "custom-spinner", | 2 | "name": "custom-spinner", | ||
| 3 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | 3 | + | "description": "An alternative busy-spinner animation for zut.", |
| 4 | "colors": { | 4 | "colors": { | ||
| 5 | "dark": { | 5 | "dark": { | ||
| 6 | "spinner_frames": [ | 6 | "spinner_frames": [ | ||
| @@ -13,9 +13,6 @@ | |||||
| 13 | "\u28EF", | 13 | "\u28EF", | ||
| 14 | "\u28F7" | 14 | "\u28F7" | ||
| 15 | ], | 15 | ], | ||
| 16 | - | "spinner_messages": [ | |||
| 17 | - | "working" | |||
| 18 | - | ], | |||
| 19 | "spinner_interval_ms": 120 | 16 | "spinner_interval_ms": 120 | ||
| 20 | } | 17 | } | ||
| 21 | } | 18 | } | ||
M examples/themes/vercel.json +0 -14
| @@ -62,13 +62,6 @@ | |||
| 62 | 62 | "\u25d1", | |
| 63 | 63 | "\u25d2" | |
| 64 | 64 | ], | |
| 65 | - | "spinner_messages": [ | |
| 66 | - | "shipping pixels", | |
| 67 | - | "deploying thoughts", | |
| 68 | - | "warming edge cache", | |
| 69 | - | "building preview", | |
| 70 | - | "checking redirects" | |
| 71 | - | ], | |
| 72 | 65 | "spinner_interval_ms": 120 | |
| 73 | 66 | }, | |
| 74 | 67 | "light": { | |
| @@ -113,13 +106,6 @@ | |||
| 113 | 106 | "\u25d1", | |
| 114 | 107 | "\u25d2" | |
| 115 | 108 | ], | |
| 116 | - | "spinner_messages": [ | |
| 117 | - | "shipping pixels", | |
| 118 | - | "deploying thoughts", | |
| 119 | - | "warming edge cache", | |
| 120 | - | "building preview", | |
| 121 | - | "checking redirects" | |
| 122 | - | ], | |
| 123 | 109 | "spinner_interval_ms": 120 | |
| 124 | 110 | } | |
| 125 | 111 | } | |
| @@ -62,13 +62,6 @@ | |||||
| 62 | "\u25d1", | 62 | "\u25d1", | ||
| 63 | "\u25d2" | 63 | "\u25d2" | ||
| 64 | ], | 64 | ], | ||
| 65 | - | "spinner_messages": [ | |||
| 66 | - | "shipping pixels", | |||
| 67 | - | "deploying thoughts", | |||
| 68 | - | "warming edge cache", | |||
| 69 | - | "building preview", | |||
| 70 | - | "checking redirects" | |||
| 71 | - | ], | |||
| 72 | "spinner_interval_ms": 120 | 65 | "spinner_interval_ms": 120 | ||
| 73 | }, | 66 | }, | ||
| 74 | "light": { | 67 | "light": { | ||
| @@ -113,13 +106,6 @@ | |||||
| 113 | "\u25d1", | 106 | "\u25d1", | ||
| 114 | "\u25d2" | 107 | "\u25d2" | ||
| 115 | ], | 108 | ], | ||
| 116 | - | "spinner_messages": [ | |||
| 117 | - | "shipping pixels", | |||
| 118 | - | "deploying thoughts", | |||
| 119 | - | "warming edge cache", | |||
| 120 | - | "building preview", | |||
| 121 | - | "checking redirects" | |||
| 122 | - | ], | |||
| 123 | "spinner_interval_ms": 120 | 109 | "spinner_interval_ms": 120 | ||
| 124 | } | 110 | } | ||
| 125 | } | 111 | } | ||
M packages/agent/cli.go +1 -0
| @@ -1492,6 +1492,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1492 | 1492 | Name: call.Name, | |
| 1493 | 1493 | Summary: core.BuildPreview(effectiveArgs, 120), | |
| 1494 | 1494 | Content: content.String(), | |
| 1495 | + | Origin: call.Origin, | |
| 1495 | 1496 | }) | |
| 1496 | 1497 | if !ok { | |
| 1497 | 1498 | return false, reason, nil | |
| @@ -1492,6 +1492,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1492 | Name: call.Name, | 1492 | Name: call.Name, | ||
| 1493 | Summary: core.BuildPreview(effectiveArgs, 120), | 1493 | Summary: core.BuildPreview(effectiveArgs, 120), | ||
| 1494 | Content: content.String(), | 1494 | Content: content.String(), | ||
| 1495 | + | Origin: call.Origin, | |||
| 1495 | }) | 1496 | }) | ||
| 1496 | if !ok { | 1497 | if !ok { | ||
| 1497 | return false, reason, nil | 1498 | return false, reason, nil | ||
A packages/agent/modes/activity.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | "unicode" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // activityKind describes a user-visible operation. It is deliberately local | |
| 13 | + | // to interactive modes: public consumers receive factual core events instead | |
| 14 | + | // of rendered UI labels. | |
| 15 | + | type activityKind string | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | activityIdle activityKind = "idle" | |
| 19 | + | activityPreparingRequest activityKind = "preparing_request" | |
| 20 | + | activitySendingRequest activityKind = "sending_request" | |
| 21 | + | activityWaitingForResponse activityKind = "waiting_for_response" | |
| 22 | + | activityReceivingResponse activityKind = "receiving_response" | |
| 23 | + | activityPreparingTool activityKind = "preparing_tool" | |
| 24 | + | activityAwaitingConfirmation activityKind = "awaiting_confirmation" | |
| 25 | + | activityRunningTool activityKind = "running_tool" | |
| 26 | + | activitySendingToolResults activityKind = "sending_tool_results" | |
| 27 | + | activityRetryingRequest activityKind = "retrying_request" | |
| 28 | + | activityCompactingHistory activityKind = "compacting_history" | |
| 29 | + | activityCondensingHistory activityKind = "condensing_history" | |
| 30 | + | activityRunningShellCommand activityKind = "running_shell_command" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | type activity struct { | |
| 34 | + | kind activityKind | |
| 35 | + | provider string | |
| 36 | + | model string | |
| 37 | + | tool string | |
| 38 | + | attempt int | |
| 39 | + | maxTries int | |
| 40 | + | retryIn time.Duration | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (a activity) label() string { | |
| 44 | + | switch a.kind { | |
| 45 | + | case activityPreparingRequest: | |
| 46 | + | return "Preparing request" | |
| 47 | + | case activitySendingRequest: | |
| 48 | + | return "Sending request to " + activityText(a.provider, "provider") | |
| 49 | + | case activityWaitingForResponse: | |
| 50 | + | return "Waiting for " + activityText(a.model, "model") + " to respond" | |
| 51 | + | case activityReceivingResponse: | |
| 52 | + | return "Receiving response from " + activityText(a.model, "model") | |
| 53 | + | case activityPreparingTool: | |
| 54 | + | return "Preparing tool: " + activityText(a.tool, "tool") | |
| 55 | + | case activityAwaitingConfirmation: | |
| 56 | + | return "Waiting for approval: " + activityText(a.tool, "tool") | |
| 57 | + | case activityRunningTool: | |
| 58 | + | return "Running tool: " + activityText(a.tool, "tool") | |
| 59 | + | case activitySendingToolResults: | |
| 60 | + | return "Sending tool results to " + activityText(a.provider, "provider") | |
| 61 | + | case activityRetryingRequest: | |
| 62 | + | if a.attempt <= 0 || a.maxTries <= 0 { | |
| 63 | + | return "Retrying request" | |
| 64 | + | } | |
| 65 | + | return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")" | |
| 66 | + | case activityCompactingHistory: | |
| 67 | + | return "Compacting conversation history" | |
| 68 | + | case activityCondensingHistory: | |
| 69 | + | return "Condensing conversation history" | |
| 70 | + | case activityRunningShellCommand: | |
| 71 | + | return "Running shell command" | |
| 72 | + | default: | |
| 73 | + | return "" | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func activityText(value, fallback string) string { | |
| 78 | + | value = strings.Map(func(r rune) rune { | |
| 79 | + | if unicode.IsControl(r) { | |
| 80 | + | return -1 | |
| 81 | + | } | |
| 82 | + | return r | |
| 83 | + | }, value) | |
| 84 | + | value = strings.TrimSpace(value) | |
| 85 | + | if value == "" { | |
| 86 | + | return fallback | |
| 87 | + | } | |
| 88 | + | return value | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // agentActivity reduces agent lifecycle facts into one current activity. It | |
| 92 | + | // tracks all pending tool calls so a multi-tool round does not claim results | |
| 93 | + | // are being sent while another tool still needs to run. | |
| 94 | + | type agentActivity struct { | |
| 95 | + | activity | |
| 96 | + | pendingToolCalls map[string]string | |
| 97 | + | toolOrder []string | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func newAgentActivity(provider, model string) agentActivity { | |
| 101 | + | return agentActivity{activity: activity{ | |
| 102 | + | kind: activityPreparingRequest, | |
| 103 | + | provider: provider, | |
| 104 | + | model: model, | |
| 105 | + | }} | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (a *agentActivity) apply(ev core.AgentEvent) { | |
| 109 | + | if a == nil { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | switch e := ev.(type) { | |
| 113 | + | case core.EvTurnStart: | |
| 114 | + | a.kind = activityPreparingRequest | |
| 115 | + | a.pendingToolCalls = nil | |
| 116 | + | a.toolOrder = nil | |
| 117 | + | case core.EvRequestStarted: | |
| 118 | + | a.kind = activitySendingRequest | |
| 119 | + | if e.Provider != "" { | |
| 120 | + | a.provider = e.Provider | |
| 121 | + | } | |
| 122 | + | if e.Model != "" { | |
| 123 | + | a.model = e.Model | |
| 124 | + | } | |
| 125 | + | case core.EvAssistantStart: | |
| 126 | + | a.kind = activityWaitingForResponse | |
| 127 | + | case core.EvTextDelta: | |
| 128 | + | a.kind = activityReceivingResponse | |
| 129 | + | case core.EvToolUseStart: | |
| 130 | + | a.kind = activityPreparingTool | |
| 131 | + | a.tool = e.Name | |
| 132 | + | case core.EvToolCall: | |
| 133 | + | if a.pendingToolCalls == nil { | |
| 134 | + | a.pendingToolCalls = make(map[string]string) | |
| 135 | + | } | |
| 136 | + | if _, exists := a.pendingToolCalls[e.ID]; !exists { | |
| 137 | + | a.toolOrder = append(a.toolOrder, e.ID) | |
| 138 | + | } | |
| 139 | + | a.pendingToolCalls[e.ID] = e.Name | |
| 140 | + | a.kind = activityPreparingTool | |
| 141 | + | a.tool = e.Name | |
| 142 | + | case core.EvToolExecutionStarted: | |
| 143 | + | a.kind = activityRunningTool | |
| 144 | + | a.tool = e.Name | |
| 145 | + | case core.EvToolResult: | |
| 146 | + | if _, exists := a.pendingToolCalls[e.ID]; !exists { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | delete(a.pendingToolCalls, e.ID) | |
| 150 | + | if len(a.pendingToolCalls) == 0 { | |
| 151 | + | a.kind = activitySendingToolResults | |
| 152 | + | a.tool = "" | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | for _, id := range a.toolOrder { | |
| 156 | + | if name, ok := a.pendingToolCalls[id]; ok { | |
| 157 | + | a.kind = activityPreparingTool | |
| 158 | + | a.tool = name | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | case core.EvRetryScheduled: | |
| 163 | + | // Agent retries discard the preceding partial assistant message. | |
| 164 | + | // Its proposed tool calls cannot run, so they must not affect the | |
| 165 | + | // next successful attempt's batch accounting. | |
| 166 | + | if e.Scope == core.RetryScopeAgent { | |
| 167 | + | a.pendingToolCalls = nil | |
| 168 | + | a.toolOrder = nil | |
| 169 | + | } | |
| 170 | + | a.kind = activityRetryingRequest | |
| 171 | + | a.attempt = e.Attempt | |
| 172 | + | a.maxTries = e.MaxAttempts | |
| 173 | + | a.retryIn = e.Delay | |
| 174 | + | } | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strconv" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | "unicode" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/core" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // activityKind describes a user-visible operation. It is deliberately local | |||
| 13 | + | // to interactive modes: public consumers receive factual core events instead | |||
| 14 | + | // of rendered UI labels. | |||
| 15 | + | type activityKind string | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | activityIdle activityKind = "idle" | |||
| 19 | + | activityPreparingRequest activityKind = "preparing_request" | |||
| 20 | + | activitySendingRequest activityKind = "sending_request" | |||
| 21 | + | activityWaitingForResponse activityKind = "waiting_for_response" | |||
| 22 | + | activityReceivingResponse activityKind = "receiving_response" | |||
| 23 | + | activityPreparingTool activityKind = "preparing_tool" | |||
| 24 | + | activityAwaitingConfirmation activityKind = "awaiting_confirmation" | |||
| 25 | + | activityRunningTool activityKind = "running_tool" | |||
| 26 | + | activitySendingToolResults activityKind = "sending_tool_results" | |||
| 27 | + | activityRetryingRequest activityKind = "retrying_request" | |||
| 28 | + | activityCompactingHistory activityKind = "compacting_history" | |||
| 29 | + | activityCondensingHistory activityKind = "condensing_history" | |||
| 30 | + | activityRunningShellCommand activityKind = "running_shell_command" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | type activity struct { | |||
| 34 | + | kind activityKind | |||
| 35 | + | provider string | |||
| 36 | + | model string | |||
| 37 | + | tool string | |||
| 38 | + | attempt int | |||
| 39 | + | maxTries int | |||
| 40 | + | retryIn time.Duration | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (a activity) label() string { | |||
| 44 | + | switch a.kind { | |||
| 45 | + | case activityPreparingRequest: | |||
| 46 | + | return "Preparing request" | |||
| 47 | + | case activitySendingRequest: | |||
| 48 | + | return "Sending request to " + activityText(a.provider, "provider") | |||
| 49 | + | case activityWaitingForResponse: | |||
| 50 | + | return "Waiting for " + activityText(a.model, "model") + " to respond" | |||
| 51 | + | case activityReceivingResponse: | |||
| 52 | + | return "Receiving response from " + activityText(a.model, "model") | |||
| 53 | + | case activityPreparingTool: | |||
| 54 | + | return "Preparing tool: " + activityText(a.tool, "tool") | |||
| 55 | + | case activityAwaitingConfirmation: | |||
| 56 | + | return "Waiting for approval: " + activityText(a.tool, "tool") | |||
| 57 | + | case activityRunningTool: | |||
| 58 | + | return "Running tool: " + activityText(a.tool, "tool") | |||
| 59 | + | case activitySendingToolResults: | |||
| 60 | + | return "Sending tool results to " + activityText(a.provider, "provider") | |||
| 61 | + | case activityRetryingRequest: | |||
| 62 | + | if a.attempt <= 0 || a.maxTries <= 0 { | |||
| 63 | + | return "Retrying request" | |||
| 64 | + | } | |||
| 65 | + | return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")" | |||
| 66 | + | case activityCompactingHistory: | |||
| 67 | + | return "Compacting conversation history" | |||
| 68 | + | case activityCondensingHistory: | |||
| 69 | + | return "Condensing conversation history" | |||
| 70 | + | case activityRunningShellCommand: | |||
| 71 | + | return "Running shell command" | |||
| 72 | + | default: | |||
| 73 | + | return "" | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func activityText(value, fallback string) string { | |||
| 78 | + | value = strings.Map(func(r rune) rune { | |||
| 79 | + | if unicode.IsControl(r) { | |||
| 80 | + | return -1 | |||
| 81 | + | } | |||
| 82 | + | return r | |||
| 83 | + | }, value) | |||
| 84 | + | value = strings.TrimSpace(value) | |||
| 85 | + | if value == "" { | |||
| 86 | + | return fallback | |||
| 87 | + | } | |||
| 88 | + | return value | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // agentActivity reduces agent lifecycle facts into one current activity. It | |||
| 92 | + | // tracks all pending tool calls so a multi-tool round does not claim results | |||
| 93 | + | // are being sent while another tool still needs to run. | |||
| 94 | + | type agentActivity struct { | |||
| 95 | + | activity | |||
| 96 | + | pendingToolCalls map[string]string | |||
| 97 | + | toolOrder []string | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func newAgentActivity(provider, model string) agentActivity { | |||
| 101 | + | return agentActivity{activity: activity{ | |||
| 102 | + | kind: activityPreparingRequest, | |||
| 103 | + | provider: provider, | |||
| 104 | + | model: model, | |||
| 105 | + | }} | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (a *agentActivity) apply(ev core.AgentEvent) { | |||
| 109 | + | if a == nil { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | switch e := ev.(type) { | |||
| 113 | + | case core.EvTurnStart: | |||
| 114 | + | a.kind = activityPreparingRequest | |||
| 115 | + | a.pendingToolCalls = nil | |||
| 116 | + | a.toolOrder = nil | |||
| 117 | + | case core.EvRequestStarted: | |||
| 118 | + | a.kind = activitySendingRequest | |||
| 119 | + | if e.Provider != "" { | |||
| 120 | + | a.provider = e.Provider | |||
| 121 | + | } | |||
| 122 | + | if e.Model != "" { | |||
| 123 | + | a.model = e.Model | |||
| 124 | + | } | |||
| 125 | + | case core.EvAssistantStart: | |||
| 126 | + | a.kind = activityWaitingForResponse | |||
| 127 | + | case core.EvTextDelta: | |||
| 128 | + | a.kind = activityReceivingResponse | |||
| 129 | + | case core.EvToolUseStart: | |||
| 130 | + | a.kind = activityPreparingTool | |||
| 131 | + | a.tool = e.Name | |||
| 132 | + | case core.EvToolCall: | |||
| 133 | + | if a.pendingToolCalls == nil { | |||
| 134 | + | a.pendingToolCalls = make(map[string]string) | |||
| 135 | + | } | |||
| 136 | + | if _, exists := a.pendingToolCalls[e.ID]; !exists { | |||
| 137 | + | a.toolOrder = append(a.toolOrder, e.ID) | |||
| 138 | + | } | |||
| 139 | + | a.pendingToolCalls[e.ID] = e.Name | |||
| 140 | + | a.kind = activityPreparingTool | |||
| 141 | + | a.tool = e.Name | |||
| 142 | + | case core.EvToolExecutionStarted: | |||
| 143 | + | a.kind = activityRunningTool | |||
| 144 | + | a.tool = e.Name | |||
| 145 | + | case core.EvToolResult: | |||
| 146 | + | if _, exists := a.pendingToolCalls[e.ID]; !exists { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | delete(a.pendingToolCalls, e.ID) | |||
| 150 | + | if len(a.pendingToolCalls) == 0 { | |||
| 151 | + | a.kind = activitySendingToolResults | |||
| 152 | + | a.tool = "" | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | for _, id := range a.toolOrder { | |||
| 156 | + | if name, ok := a.pendingToolCalls[id]; ok { | |||
| 157 | + | a.kind = activityPreparingTool | |||
| 158 | + | a.tool = name | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | case core.EvRetryScheduled: | |||
| 163 | + | // Agent retries discard the preceding partial assistant message. | |||
| 164 | + | // Its proposed tool calls cannot run, so they must not affect the | |||
| 165 | + | // next successful attempt's batch accounting. | |||
| 166 | + | if e.Scope == core.RetryScopeAgent { | |||
| 167 | + | a.pendingToolCalls = nil | |||
| 168 | + | a.toolOrder = nil | |||
| 169 | + | } | |||
| 170 | + | a.kind = activityRetryingRequest | |||
| 171 | + | a.attempt = e.Attempt | |||
| 172 | + | a.maxTries = e.MaxAttempts | |||
| 173 | + | a.retryIn = e.Delay | |||
| 174 | + | } | |||
| 175 | + | } | |||
A packages/agent/modes/activity_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestAgentActivityDescribesLifecycle(t *testing.T) { | |
| 12 | + | a := newAgentActivity("anthropic", "claude-test") | |
| 13 | + | check := func(want string) { | |
| 14 | + | t.Helper() | |
| 15 | + | if got := a.label(); got != want { | |
| 16 | + | t.Fatalf("label = %q, want %q", got, want) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | check("Preparing request") | |
| 21 | + | a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3}) | |
| 22 | + | check("Sending request to anthropic") | |
| 23 | + | a.apply(core.EvAssistantStart{}) | |
| 24 | + | check("Waiting for claude-test to respond") | |
| 25 | + | a.apply(core.EvTextDelta{Delta: "hello"}) | |
| 26 | + | check("Receiving response from claude-test") | |
| 27 | + | a.apply(core.EvToolUseStart{ID: "one", Name: "read"}) | |
| 28 | + | check("Preparing tool: read") | |
| 29 | + | a.apply(core.EvToolCall{ID: "one", Name: "read"}) | |
| 30 | + | a.apply(core.EvToolCall{ID: "two", Name: "bash"}) | |
| 31 | + | a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"}) | |
| 32 | + | check("Running tool: read") | |
| 33 | + | a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}}) | |
| 34 | + | check("Preparing tool: bash") | |
| 35 | + | a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"}) | |
| 36 | + | a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}}) | |
| 37 | + | check("Sending tool results to anthropic") | |
| 38 | + | a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second}) | |
| 39 | + | check("Retrying request in 2s (attempt 2/4)") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestActivityLabelsSanitizeAndFallback(t *testing.T) { | |
| 43 | + | a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"} | |
| 44 | + | if got, want := a.label(), "Sending request to [31mremote"; got != want { | |
| 45 | + | t.Fatalf("sanitized label = %q, want %q", got, want) | |
| 46 | + | } | |
| 47 | + | a = activity{kind: activityRunningTool} | |
| 48 | + | if got, want := a.label(), "Running tool: tool"; got != want { | |
| 49 | + | t.Fatalf("fallback label = %q, want %q", got, want) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) { | |
| 54 | + | a := newAgentActivity("provider", "model") | |
| 55 | + | a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}}) | |
| 56 | + | if got, want := a.label(), "Preparing request"; got != want { | |
| 57 | + | t.Fatalf("label = %q, want %q", got, want) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) { | |
| 62 | + | a := newAgentActivity("provider", "model") | |
| 63 | + | a.apply(core.EvToolCall{ID: "discarded", Name: "stale"}) | |
| 64 | + | a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond}) | |
| 65 | + | a.apply(core.EvToolCall{ID: "actual", Name: "read"}) | |
| 66 | + | a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}}) | |
| 67 | + | ||
| 68 | + | if got, want := a.label(), "Sending tool results to provider"; got != want { | |
| 69 | + | t.Fatalf("label = %q, want %q", got, want) | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestAgentActivityDescribesLifecycle(t *testing.T) { | |||
| 12 | + | a := newAgentActivity("anthropic", "claude-test") | |||
| 13 | + | check := func(want string) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | if got := a.label(); got != want { | |||
| 16 | + | t.Fatalf("label = %q, want %q", got, want) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | check("Preparing request") | |||
| 21 | + | a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3}) | |||
| 22 | + | check("Sending request to anthropic") | |||
| 23 | + | a.apply(core.EvAssistantStart{}) | |||
| 24 | + | check("Waiting for claude-test to respond") | |||
| 25 | + | a.apply(core.EvTextDelta{Delta: "hello"}) | |||
| 26 | + | check("Receiving response from claude-test") | |||
| 27 | + | a.apply(core.EvToolUseStart{ID: "one", Name: "read"}) | |||
| 28 | + | check("Preparing tool: read") | |||
| 29 | + | a.apply(core.EvToolCall{ID: "one", Name: "read"}) | |||
| 30 | + | a.apply(core.EvToolCall{ID: "two", Name: "bash"}) | |||
| 31 | + | a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"}) | |||
| 32 | + | check("Running tool: read") | |||
| 33 | + | a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}}) | |||
| 34 | + | check("Preparing tool: bash") | |||
| 35 | + | a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"}) | |||
| 36 | + | a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}}) | |||
| 37 | + | check("Sending tool results to anthropic") | |||
| 38 | + | a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second}) | |||
| 39 | + | check("Retrying request in 2s (attempt 2/4)") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestActivityLabelsSanitizeAndFallback(t *testing.T) { | |||
| 43 | + | a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"} | |||
| 44 | + | if got, want := a.label(), "Sending request to [31mremote"; got != want { | |||
| 45 | + | t.Fatalf("sanitized label = %q, want %q", got, want) | |||
| 46 | + | } | |||
| 47 | + | a = activity{kind: activityRunningTool} | |||
| 48 | + | if got, want := a.label(), "Running tool: tool"; got != want { | |||
| 49 | + | t.Fatalf("fallback label = %q, want %q", got, want) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) { | |||
| 54 | + | a := newAgentActivity("provider", "model") | |||
| 55 | + | a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}}) | |||
| 56 | + | if got, want := a.label(), "Preparing request"; got != want { | |||
| 57 | + | t.Fatalf("label = %q, want %q", got, want) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) { | |||
| 62 | + | a := newAgentActivity("provider", "model") | |||
| 63 | + | a.apply(core.EvToolCall{ID: "discarded", Name: "stale"}) | |||
| 64 | + | a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond}) | |||
| 65 | + | a.apply(core.EvToolCall{ID: "actual", Name: "read"}) | |||
| 66 | + | a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}}) | |||
| 67 | + | ||||
| 68 | + | if got, want := a.label(), "Sending tool results to provider"; got != want { | |||
| 69 | + | t.Fatalf("label = %q, want %q", got, want) | |||
| 70 | + | } | |||
| 71 | + | } | |||
M packages/agent/modes/btw_dialog.go +74 -23
| @@ -2,6 +2,7 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| @@ -38,13 +39,13 @@ type btwDialog struct { | |||
| 38 | 39 | compactMode bool | |
| 39 | 40 | lineInput bool | |
| 40 | 41 | cancel context.CancelFunc | |
| 42 | + | generation uint64 | |
| 43 | + | closeHook func() | |
| 41 | 44 | ||
| 42 | - | // spin drives the same braille animation + rotating funny-line | |
| 43 | - | // shown in the main status bar. Owned by the dialog so its clock | |
| 44 | - | // is independent of the main spinner (so re-opening the dialog | |
| 45 | - | // always starts fresh and the message doesn't carry over from a | |
| 46 | - | // completed main turn). | |
| 47 | - | spin *spinner | |
| 45 | + | // spin drives the same braille animation as the main status bar. Its | |
| 46 | + | // activity label is owned by this dialog under d.mu. | |
| 47 | + | spin *spinner | |
| 48 | + | activity agentActivity | |
| 48 | 49 | ||
| 49 | 50 | // sideAgent owns an isolated transcript seeded from the main | |
| 50 | 51 | // conversation. It uses the same tools and runtime hooks as the | |
| @@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog { | |||
| 68 | 69 | return &btwDialog{} | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 72 | + | func btwOrigin(generation uint64) string { | |
| 73 | + | return fmt.Sprintf("btw:%d", generation) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func isBtwOrigin(origin string) bool { | |
| 77 | + | return strings.HasPrefix(origin, "btw:") | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (d *btwDialog) setCloseHook(hook func()) { | |
| 81 | + | if d == nil { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | d.mu.Lock() | |
| 85 | + | d.closeHook = hook | |
| 86 | + | d.mu.Unlock() | |
| 87 | + | } | |
| 88 | + | ||
| 71 | 89 | // Active reports whether the dialog is visible and consuming keys. | |
| 72 | 90 | func (d *btwDialog) Active() bool { | |
| 73 | 91 | if d == nil { | |
| @@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool { | |||
| 92 | 110 | return d.active && d.loading | |
| 93 | 111 | } | |
| 94 | 112 | ||
| 95 | - | // SetToolPreview attaches the side-effect-free confirmation preview to the | |
| 96 | - | // matching side-chat tool box before execution begins. | |
| 97 | - | func (d *btwDialog) SetToolPreview(id, summary, content string) bool { | |
| 98 | - | if d == nil || id == "" { | |
| 113 | + | // enqueueToolConfirmation attaches the side-effect-free confirmation preview | |
| 114 | + | // to the matching side-chat tool box and queues its prompt while d.mu is | |
| 115 | + | // held. Keeping both actions together prevents Close from advancing the | |
| 116 | + | // generation between the ownership check and the confirmation enqueue. | |
| 117 | + | func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool { | |
| 118 | + | if d == nil || id == "" || enqueue == nil { | |
| 99 | 119 | return false | |
| 100 | 120 | } | |
| 101 | 121 | d.mu.Lock() | |
| 102 | 122 | defer d.mu.Unlock() | |
| 123 | + | if !d.active || origin != btwOrigin(d.generation) { | |
| 124 | + | return false | |
| 125 | + | } | |
| 103 | 126 | for turnIdx := range d.turns { | |
| 104 | 127 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | |
| 105 | 128 | tool.Args = summary | |
| 106 | 129 | tool.Preview = content | |
| 130 | + | d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name} | |
| 131 | + | enqueue() | |
| 107 | 132 | return true | |
| 108 | 133 | } | |
| 109 | 134 | } | |
| @@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() { | |||
| 130 | 155 | // host redraw loop can pick up the update without polling. | |
| 131 | 156 | func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) { | |
| 132 | 157 | d.mu.Lock() | |
| 158 | + | d.generation++ | |
| 133 | 159 | d.active = true | |
| 134 | 160 | d.theme = th | |
| 135 | 161 | d.compactMode = compactMode | |
| @@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||
| 138 | 164 | d.turns = nil | |
| 139 | 165 | d.loading = false | |
| 140 | 166 | d.cancel = nil | |
| 167 | + | d.activity = agentActivity{} | |
| 141 | 168 | prompt := th.AccentBar(th.Accent) | |
| 142 | 169 | if lineInput { | |
| 143 | 170 | prompt = "" | |
| 144 | 171 | } | |
| 145 | 172 | d.editor = tui.NewEditor(prompt) | |
| 146 | - | d.sideAgent = newBtwAgent(agent, system, model) | |
| 173 | + | d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation)) | |
| 147 | 174 | d.cwd = cwd | |
| 148 | 175 | d.mu.Unlock() | |
| 149 | 176 | ||
| @@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||
| 156 | 183 | // Close hides the dialog. Cancels any in-flight request. | |
| 157 | 184 | func (d *btwDialog) Close() { | |
| 158 | 185 | d.mu.Lock() | |
| 186 | + | d.generation++ | |
| 159 | 187 | d.active = false | |
| 160 | 188 | d.turns = nil | |
| 161 | 189 | d.editor = nil | |
| 162 | 190 | d.loading = false | |
| 191 | + | d.activity = agentActivity{} | |
| 163 | 192 | cancel := d.cancel | |
| 164 | 193 | d.cancel = nil | |
| 165 | 194 | d.sideAgent = nil | |
| 166 | 195 | d.toolView = nil | |
| 196 | + | closeHook := d.closeHook | |
| 167 | 197 | d.mu.Unlock() | |
| 168 | 198 | if cancel != nil { | |
| 169 | 199 | cancel() | |
| 170 | 200 | } | |
| 201 | + | if closeHook != nil { | |
| 202 | + | closeHook() | |
| 203 | + | } | |
| 171 | 204 | } | |
| 172 | 205 | ||
| 173 | 206 | // HandleKey routes a keypress to the dialog. Returns true if the | |
| @@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 247 | 280 | d.spin.Start() | |
| 248 | 281 | d.turns = append(d.turns, btwTurn{User: question}) | |
| 249 | 282 | turnIdx := len(d.turns) - 1 | |
| 283 | + | generation := d.generation | |
| 250 | 284 | ||
| 251 | 285 | ctx, cancel := context.WithCancel(context.Background()) | |
| 252 | 286 | d.cancel = cancel | |
| 253 | 287 | agent := d.sideAgent | |
| 288 | + | providerName := "" | |
| 289 | + | model := "" | |
| 290 | + | if agent != nil { | |
| 291 | + | model = agent.Model | |
| 292 | + | if agent.Client != nil { | |
| 293 | + | providerName = agent.Client.Name() | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | d.activity = newAgentActivity(providerName, model) | |
| 254 | 297 | d.mu.Unlock() | |
| 255 | 298 | ||
| 256 | 299 | go func() { | |
| 257 | 300 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | |
| 258 | - | d.handleAgentEvent(turnIdx, ev) | |
| 301 | + | d.handleAgentEvent(turnIdx, generation, ev) | |
| 259 | 302 | if invalidate != nil { | |
| 260 | 303 | invalidate() | |
| 261 | 304 | } | |
| @@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 264 | 307 | if err != nil && ctx.Err() == nil { | |
| 265 | 308 | errMsg = err.Error() | |
| 266 | 309 | } | |
| 267 | - | d.completeTurn(turnIdx, errMsg) | |
| 310 | + | d.completeTurn(turnIdx, generation, errMsg) | |
| 268 | 311 | if invalidate != nil { | |
| 269 | 312 | invalidate() | |
| 270 | 313 | } | |
| 271 | 314 | }() | |
| 272 | 315 | } | |
| 273 | 316 | ||
| 274 | - | func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |
| 317 | + | func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent { | |
| 275 | 318 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | |
| 276 | 319 | agent.MaxSteps = main.MaxSteps | |
| 277 | 320 | agent.Reasoning = main.Reasoning | |
| 278 | 321 | agent.Temperature = main.Temperature | |
| 279 | 322 | agent.FastMode = main.FastModeEnabled() | |
| 280 | 323 | agent.MaxTokens = main.MaxTokens | |
| 281 | - | agent.BeforeToolExecute = main.BeforeToolExecute | |
| 324 | + | if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil { | |
| 325 | + | agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 326 | + | call.Origin = origin | |
| 327 | + | return beforeToolExecute(call) | |
| 328 | + | } | |
| 329 | + | } | |
| 282 | 330 | agent.BeforeTurn = main.BeforeTurn | |
| 283 | 331 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | |
| 284 | 332 | agent.MaxRetries = main.MaxRetries | |
| @@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||
| 288 | 336 | return agent | |
| 289 | 337 | } | |
| 290 | 338 | ||
| 291 | - | func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) { | |
| 339 | + | func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) { | |
| 292 | 340 | d.mu.Lock() | |
| 293 | 341 | defer d.mu.Unlock() | |
| 294 | - | if turnIdx < 0 || turnIdx >= len(d.turns) { | |
| 342 | + | if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) { | |
| 295 | 343 | return | |
| 296 | 344 | } | |
| 297 | 345 | turn := &d.turns[turnIdx] | |
| 346 | + | d.activity.apply(ev) | |
| 298 | 347 | switch e := ev.(type) { | |
| 299 | 348 | case core.EvTextDelta: | |
| 300 | 349 | turn.Assistant += e.Delta | |
| @@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView { | |||
| 353 | 402 | } | |
| 354 | 403 | ||
| 355 | 404 | // completeTurn records an error, if any, and clears the loading state. | |
| 356 | - | func (d *btwDialog) completeTurn(idx int, errMsg string) { | |
| 405 | + | func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) { | |
| 357 | 406 | d.mu.Lock() | |
| 358 | 407 | defer d.mu.Unlock() | |
| 359 | - | if idx < 0 || idx >= len(d.turns) { | |
| 408 | + | if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) { | |
| 360 | 409 | return | |
| 361 | 410 | } | |
| 362 | 411 | d.turns[idx].Err = errMsg | |
| 363 | 412 | d.loading = false | |
| 413 | + | d.activity = agentActivity{} | |
| 364 | 414 | d.cancel = nil | |
| 365 | 415 | } | |
| 366 | 416 | ||
| @@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 408 | 458 | ||
| 409 | 459 | if d.loading && d.spin != nil { | |
| 410 | 460 | out = append(out, "") | |
| 411 | - | // Match the main chat busy prefix shape: spinner glyph, | |
| 412 | - | // rotating funny-line, elapsed seconds, then a muted hint | |
| 413 | - | // that esc cancels. | |
| 461 | + | label := d.activity.label() | |
| 462 | + | if label == "" { | |
| 463 | + | label = "Preparing request" | |
| 464 | + | } | |
| 414 | 465 | prefix := fmt.Sprintf("%s %s - %s", | |
| 415 | 466 | th.FGColor(th.Assistant, d.spin.Frame()), | |
| 416 | - | th.FGColor(th.Assistant, d.spin.Message()), | |
| 467 | + | th.FGColor(th.Assistant, label), | |
| 417 | 468 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | |
| 418 | 469 | ) | |
| 419 | 470 | out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)")) | |
| @@ -2,6 +2,7 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| @@ -38,13 +39,13 @@ type btwDialog struct { | |||||
| 38 | compactMode bool | 39 | compactMode bool | ||
| 39 | lineInput bool | 40 | lineInput bool | ||
| 40 | cancel context.CancelFunc | 41 | cancel context.CancelFunc | ||
| 42 | + | generation uint64 | |||
| 43 | + | closeHook func() | |||
| 41 | 44 | ||||
| 42 | - | // spin drives the same braille animation + rotating funny-line | 45 | + | // spin drives the same braille animation as the main status bar. Its |
| 43 | - | // shown in the main status bar. Owned by the dialog so its clock | 46 | + | // activity label is owned by this dialog under d.mu. |
| 44 | - | // is independent of the main spinner (so re-opening the dialog | 47 | + | spin *spinner |
| 45 | - | // always starts fresh and the message doesn't carry over from a | 48 | + | activity agentActivity |
| 46 | - | // completed main turn). | |||
| 47 | - | spin *spinner | |||
| 48 | 49 | ||||
| 49 | // sideAgent owns an isolated transcript seeded from the main | 50 | // sideAgent owns an isolated transcript seeded from the main | ||
| 50 | // conversation. It uses the same tools and runtime hooks as the | 51 | // conversation. It uses the same tools and runtime hooks as the | ||
| @@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog { | |||||
| 68 | return &btwDialog{} | 69 | return &btwDialog{} | ||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
| 72 | + | func btwOrigin(generation uint64) string { | |||
| 73 | + | return fmt.Sprintf("btw:%d", generation) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func isBtwOrigin(origin string) bool { | |||
| 77 | + | return strings.HasPrefix(origin, "btw:") | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (d *btwDialog) setCloseHook(hook func()) { | |||
| 81 | + | if d == nil { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | d.mu.Lock() | |||
| 85 | + | d.closeHook = hook | |||
| 86 | + | d.mu.Unlock() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 71 | // Active reports whether the dialog is visible and consuming keys. | 89 | // Active reports whether the dialog is visible and consuming keys. | ||
| 72 | func (d *btwDialog) Active() bool { | 90 | func (d *btwDialog) Active() bool { | ||
| 73 | if d == nil { | 91 | if d == nil { | ||
| @@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool { | |||||
| 92 | return d.active && d.loading | 110 | return d.active && d.loading | ||
| 93 | } | 111 | } | ||
| 94 | 112 | ||||
| 95 | - | // SetToolPreview attaches the side-effect-free confirmation preview to the | 113 | + | // enqueueToolConfirmation attaches the side-effect-free confirmation preview |
| 96 | - | // matching side-chat tool box before execution begins. | 114 | + | // to the matching side-chat tool box and queues its prompt while d.mu is |
| 97 | - | func (d *btwDialog) SetToolPreview(id, summary, content string) bool { | 115 | + | // held. Keeping both actions together prevents Close from advancing the |
| 98 | - | if d == nil || id == "" { | 116 | + | // generation between the ownership check and the confirmation enqueue. |
| 117 | + | func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool { | |||
| 118 | + | if d == nil || id == "" || enqueue == nil { | |||
| 99 | return false | 119 | return false | ||
| 100 | } | 120 | } | ||
| 101 | d.mu.Lock() | 121 | d.mu.Lock() | ||
| 102 | defer d.mu.Unlock() | 122 | defer d.mu.Unlock() | ||
| 123 | + | if !d.active || origin != btwOrigin(d.generation) { | |||
| 124 | + | return false | |||
| 125 | + | } | |||
| 103 | for turnIdx := range d.turns { | 126 | for turnIdx := range d.turns { | ||
| 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | 127 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | ||
| 105 | tool.Args = summary | 128 | tool.Args = summary | ||
| 106 | tool.Preview = content | 129 | tool.Preview = content | ||
| 130 | + | d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name} | |||
| 131 | + | enqueue() | |||
| 107 | return true | 132 | return true | ||
| 108 | } | 133 | } | ||
| 109 | } | 134 | } | ||
| @@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() { | |||||
| 130 | // host redraw loop can pick up the update without polling. | 155 | // host redraw loop can pick up the update without polling. | ||
| 131 | func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) { | 156 | func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) { | ||
| 132 | d.mu.Lock() | 157 | d.mu.Lock() | ||
| 158 | + | d.generation++ | |||
| 133 | d.active = true | 159 | d.active = true | ||
| 134 | d.theme = th | 160 | d.theme = th | ||
| 135 | d.compactMode = compactMode | 161 | d.compactMode = compactMode | ||
| @@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||||
| 138 | d.turns = nil | 164 | d.turns = nil | ||
| 139 | d.loading = false | 165 | d.loading = false | ||
| 140 | d.cancel = nil | 166 | d.cancel = nil | ||
| 167 | + | d.activity = agentActivity{} | |||
| 141 | prompt := th.AccentBar(th.Accent) | 168 | prompt := th.AccentBar(th.Accent) | ||
| 142 | if lineInput { | 169 | if lineInput { | ||
| 143 | prompt = "" | 170 | prompt = "" | ||
| 144 | } | 171 | } | ||
| 145 | d.editor = tui.NewEditor(prompt) | 172 | d.editor = tui.NewEditor(prompt) | ||
| 146 | - | d.sideAgent = newBtwAgent(agent, system, model) | 173 | + | d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation)) |
| 147 | d.cwd = cwd | 174 | d.cwd = cwd | ||
| 148 | d.mu.Unlock() | 175 | d.mu.Unlock() | ||
| 149 | 176 | ||||
| @@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||||
| 156 | // Close hides the dialog. Cancels any in-flight request. | 183 | // Close hides the dialog. Cancels any in-flight request. | ||
| 157 | func (d *btwDialog) Close() { | 184 | func (d *btwDialog) Close() { | ||
| 158 | d.mu.Lock() | 185 | d.mu.Lock() | ||
| 186 | + | d.generation++ | |||
| 159 | d.active = false | 187 | d.active = false | ||
| 160 | d.turns = nil | 188 | d.turns = nil | ||
| 161 | d.editor = nil | 189 | d.editor = nil | ||
| 162 | d.loading = false | 190 | d.loading = false | ||
| 191 | + | d.activity = agentActivity{} | |||
| 163 | cancel := d.cancel | 192 | cancel := d.cancel | ||
| 164 | d.cancel = nil | 193 | d.cancel = nil | ||
| 165 | d.sideAgent = nil | 194 | d.sideAgent = nil | ||
| 166 | d.toolView = nil | 195 | d.toolView = nil | ||
| 196 | + | closeHook := d.closeHook | |||
| 167 | d.mu.Unlock() | 197 | d.mu.Unlock() | ||
| 168 | if cancel != nil { | 198 | if cancel != nil { | ||
| 169 | cancel() | 199 | cancel() | ||
| 170 | } | 200 | } | ||
| 201 | + | if closeHook != nil { | |||
| 202 | + | closeHook() | |||
| 203 | + | } | |||
| 171 | } | 204 | } | ||
| 172 | 205 | ||||
| 173 | // HandleKey routes a keypress to the dialog. Returns true if the | 206 | // HandleKey routes a keypress to the dialog. Returns true if the | ||
| @@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 247 | d.spin.Start() | 280 | d.spin.Start() | ||
| 248 | d.turns = append(d.turns, btwTurn{User: question}) | 281 | d.turns = append(d.turns, btwTurn{User: question}) | ||
| 249 | turnIdx := len(d.turns) - 1 | 282 | turnIdx := len(d.turns) - 1 | ||
| 283 | + | generation := d.generation | |||
| 250 | 284 | ||||
| 251 | ctx, cancel := context.WithCancel(context.Background()) | 285 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 252 | d.cancel = cancel | 286 | d.cancel = cancel | ||
| 253 | agent := d.sideAgent | 287 | agent := d.sideAgent | ||
| 288 | + | providerName := "" | |||
| 289 | + | model := "" | |||
| 290 | + | if agent != nil { | |||
| 291 | + | model = agent.Model | |||
| 292 | + | if agent.Client != nil { | |||
| 293 | + | providerName = agent.Client.Name() | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | d.activity = newAgentActivity(providerName, model) | |||
| 254 | d.mu.Unlock() | 297 | d.mu.Unlock() | ||
| 255 | 298 | ||||
| 256 | go func() { | 299 | go func() { | ||
| 257 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | 300 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | ||
| 258 | - | d.handleAgentEvent(turnIdx, ev) | 301 | + | d.handleAgentEvent(turnIdx, generation, ev) |
| 259 | if invalidate != nil { | 302 | if invalidate != nil { | ||
| 260 | invalidate() | 303 | invalidate() | ||
| 261 | } | 304 | } | ||
| @@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 264 | if err != nil && ctx.Err() == nil { | 307 | if err != nil && ctx.Err() == nil { | ||
| 265 | errMsg = err.Error() | 308 | errMsg = err.Error() | ||
| 266 | } | 309 | } | ||
| 267 | - | d.completeTurn(turnIdx, errMsg) | 310 | + | d.completeTurn(turnIdx, generation, errMsg) |
| 268 | if invalidate != nil { | 311 | if invalidate != nil { | ||
| 269 | invalidate() | 312 | invalidate() | ||
| 270 | } | 313 | } | ||
| 271 | }() | 314 | }() | ||
| 272 | } | 315 | } | ||
| 273 | 316 | ||||
| 274 | - | func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | 317 | + | func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent { |
| 275 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | 318 | agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot()) | ||
| 276 | agent.MaxSteps = main.MaxSteps | 319 | agent.MaxSteps = main.MaxSteps | ||
| 277 | agent.Reasoning = main.Reasoning | 320 | agent.Reasoning = main.Reasoning | ||
| 278 | agent.Temperature = main.Temperature | 321 | agent.Temperature = main.Temperature | ||
| 279 | agent.FastMode = main.FastModeEnabled() | 322 | agent.FastMode = main.FastModeEnabled() | ||
| 280 | agent.MaxTokens = main.MaxTokens | 323 | agent.MaxTokens = main.MaxTokens | ||
| 281 | - | agent.BeforeToolExecute = main.BeforeToolExecute | 324 | + | if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil { |
| 325 | + | agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 326 | + | call.Origin = origin | |||
| 327 | + | return beforeToolExecute(call) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 282 | agent.BeforeTurn = main.BeforeTurn | 330 | agent.BeforeTurn = main.BeforeTurn | ||
| 283 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | 331 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | ||
| 284 | agent.MaxRetries = main.MaxRetries | 332 | agent.MaxRetries = main.MaxRetries | ||
| @@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||||
| 288 | return agent | 336 | return agent | ||
| 289 | } | 337 | } | ||
| 290 | 338 | ||||
| 291 | - | func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) { | 339 | + | func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) { |
| 292 | d.mu.Lock() | 340 | d.mu.Lock() | ||
| 293 | defer d.mu.Unlock() | 341 | defer d.mu.Unlock() | ||
| 294 | - | if turnIdx < 0 || turnIdx >= len(d.turns) { | 342 | + | if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) { |
| 295 | return | 343 | return | ||
| 296 | } | 344 | } | ||
| 297 | turn := &d.turns[turnIdx] | 345 | turn := &d.turns[turnIdx] | ||
| 346 | + | d.activity.apply(ev) | |||
| 298 | switch e := ev.(type) { | 347 | switch e := ev.(type) { | ||
| 299 | case core.EvTextDelta: | 348 | case core.EvTextDelta: | ||
| 300 | turn.Assistant += e.Delta | 349 | turn.Assistant += e.Delta | ||
| @@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView { | |||||
| 353 | } | 402 | } | ||
| 354 | 403 | ||||
| 355 | // completeTurn records an error, if any, and clears the loading state. | 404 | // completeTurn records an error, if any, and clears the loading state. | ||
| 356 | - | func (d *btwDialog) completeTurn(idx int, errMsg string) { | 405 | + | func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) { |
| 357 | d.mu.Lock() | 406 | d.mu.Lock() | ||
| 358 | defer d.mu.Unlock() | 407 | defer d.mu.Unlock() | ||
| 359 | - | if idx < 0 || idx >= len(d.turns) { | 408 | + | if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) { |
| 360 | return | 409 | return | ||
| 361 | } | 410 | } | ||
| 362 | d.turns[idx].Err = errMsg | 411 | d.turns[idx].Err = errMsg | ||
| 363 | d.loading = false | 412 | d.loading = false | ||
| 413 | + | d.activity = agentActivity{} | |||
| 364 | d.cancel = nil | 414 | d.cancel = nil | ||
| 365 | } | 415 | } | ||
| 366 | 416 | ||||
| @@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||||
| 408 | 458 | ||||
| 409 | if d.loading && d.spin != nil { | 459 | if d.loading && d.spin != nil { | ||
| 410 | out = append(out, "") | 460 | out = append(out, "") | ||
| 411 | - | // Match the main chat busy prefix shape: spinner glyph, | 461 | + | label := d.activity.label() |
| 412 | - | // rotating funny-line, elapsed seconds, then a muted hint | 462 | + | if label == "" { |
| 413 | - | // that esc cancels. | 463 | + | label = "Preparing request" |
| 464 | + | } | |||
| 414 | prefix := fmt.Sprintf("%s %s - %s", | 465 | prefix := fmt.Sprintf("%s %s - %s", | ||
| 415 | th.FGColor(th.Assistant, d.spin.Frame()), | 466 | th.FGColor(th.Assistant, d.spin.Frame()), | ||
| 416 | - | th.FGColor(th.Assistant, d.spin.Message()), | 467 | + | th.FGColor(th.Assistant, label), |
| 417 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | 468 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | ||
| 418 | ) | 469 | ) | ||
| 419 | out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)")) | 470 | out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)")) | ||
M packages/agent/modes/btw_dialog_test.go +124 -1
| @@ -128,8 +128,10 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||
| 128 | 128 | Content: []provider.Content{provider.TextBlock{Text: "frozen context"}}, | |
| 129 | 129 | }}) | |
| 130 | 130 | var policyCalls atomic.Int32 | |
| 131 | - | main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 131 | + | var policyOrigin string | |
| 132 | + | main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 132 | 133 | policyCalls.Add(1) | |
| 134 | + | policyOrigin = call.Origin | |
| 133 | 135 | return true, "", nil | |
| 134 | 136 | } | |
| 135 | 137 | ||
| @@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||
| 155 | 157 | if got := policyCalls.Load(); got != 1 { | |
| 156 | 158 | t.Fatalf("main agent tool-policy calls = %d, want 1", got) | |
| 157 | 159 | } | |
| 160 | + | if policyOrigin != btwOrigin(1) { | |
| 161 | + | t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1)) | |
| 162 | + | } | |
| 158 | 163 | ||
| 159 | 164 | client.mu.Lock() | |
| 160 | 165 | requests := append([]provider.Request(nil), client.requests...) | |
| @@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||
| 201 | 206 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | |
| 202 | 207 | } | |
| 203 | 208 | } | |
| 209 | + | ||
| 210 | + | func TestBtwActivityRendersFactDrivenLabel(t *testing.T) { | |
| 211 | + | d := &btwDialog{ | |
| 212 | + | active: true, | |
| 213 | + | loading: true, | |
| 214 | + | theme: tui.Dark, | |
| 215 | + | toolView: &tui.View{Theme: tui.Dark}, | |
| 216 | + | spin: newSpinner(tui.Dark), | |
| 217 | + | activity: newAgentActivity("anthropic", "claude-test"), | |
| 218 | + | } | |
| 219 | + | d.spin.Start() | |
| 220 | + | d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3}) | |
| 221 | + | ||
| 222 | + | rendered := strings.Join(d.Render(tui.Dark, 80), "\n") | |
| 223 | + | if !strings.Contains(rendered, "Sending request to anthropic") { | |
| 224 | + | t.Fatalf("/btw activity = %q, want factual request label", rendered) | |
| 225 | + | } | |
| 226 | + | if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") { | |
| 227 | + | t.Fatalf("/btw activity retained random spinner wording: %q", rendered) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) { | |
| 232 | + | d := &btwDialog{ | |
| 233 | + | active: true, | |
| 234 | + | loading: true, | |
| 235 | + | generation: 2, | |
| 236 | + | turns: []btwTurn{{User: "new question"}}, | |
| 237 | + | activity: newAgentActivity("provider", "model"), | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"}) | |
| 241 | + | d.completeTurn(0, 1, "stale error") | |
| 242 | + | ||
| 243 | + | d.mu.Lock() | |
| 244 | + | defer d.mu.Unlock() | |
| 245 | + | if d.turns[0].Assistant != "" || d.turns[0].Err != "" { | |
| 246 | + | t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0]) | |
| 247 | + | } | |
| 248 | + | if !d.loading { | |
| 249 | + | t.Fatal("stale completion cleared the reopened dialog loading state") | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) { | |
| 254 | + | client := &btwToolTestClient{done: make(chan struct{})} | |
| 255 | + | tool := &btwEchoTool{} | |
| 256 | + | main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool}) | |
| 257 | + | d := newBtwDialog() | |
| 258 | + | i := &Interactive{ | |
| 259 | + | btwDialog: d, | |
| 260 | + | confirmDialog: newConfirmDialog(), | |
| 261 | + | dirty: make(chan struct{}, 1), | |
| 262 | + | } | |
| 263 | + | gate := core.NewConfirmGate(i) | |
| 264 | + | mainResp := make(chan core.ConfirmDecision, 1) | |
| 265 | + | i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp}) | |
| 266 | + | i.confirmDialog.Blur() | |
| 267 | + | main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 268 | + | return gate.CheckToolCall(core.ToolCallConfirmation{ | |
| 269 | + | ID: call.ID, | |
| 270 | + | Name: call.Name, | |
| 271 | + | Summary: "hello", | |
| 272 | + | Origin: call.Origin, | |
| 273 | + | }) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil) | |
| 277 | + | sideConfirmationFirst := func() bool { | |
| 278 | + | i.confirmDialog.mu.Lock() | |
| 279 | + | defer i.confirmDialog.mu.Unlock() | |
| 280 | + | return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo" | |
| 281 | + | } | |
| 282 | + | deadline := time.Now().Add(2 * time.Second) | |
| 283 | + | for !sideConfirmationFirst() && time.Now().Before(deadline) { | |
| 284 | + | time.Sleep(time.Millisecond) | |
| 285 | + | } | |
| 286 | + | if !sideConfirmationFirst() { | |
| 287 | + | t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue") | |
| 288 | + | } | |
| 289 | + | if !i.confirmDialog.Focused() { | |
| 290 | + | t.Fatal("side-agent confirmation did not focus the prioritized dialog") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | d.mu.Lock() | |
| 294 | + | if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 { | |
| 295 | + | d.mu.Unlock() | |
| 296 | + | t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns) | |
| 297 | + | } | |
| 298 | + | if got, want := d.turns[0].Tools[0].Args, "hello"; got != want { | |
| 299 | + | d.mu.Unlock() | |
| 300 | + | t.Fatalf("side-agent confirmation summary = %q, want %q", got, want) | |
| 301 | + | } | |
| 302 | + | if got, want := d.activity.label(), "Waiting for approval: echo"; got != want { | |
| 303 | + | d.mu.Unlock() | |
| 304 | + | t.Fatalf("side-agent activity = %q, want %q", got, want) | |
| 305 | + | } | |
| 306 | + | d.mu.Unlock() | |
| 307 | + | ||
| 308 | + | i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 309 | + | select { | |
| 310 | + | case <-client.done: | |
| 311 | + | case <-time.After(2 * time.Second): | |
| 312 | + | t.Fatal("side-agent did not continue after confirmation") | |
| 313 | + | } | |
| 314 | + | if got := tool.runs.Load(); got != 1 { | |
| 315 | + | t.Fatalf("tool executions = %d, want 1 after confirmation", got) | |
| 316 | + | } | |
| 317 | + | if i.confirmDialog.Focused() { | |
| 318 | + | t.Fatal("main confirmation regained focus instead of returning input to /btw") | |
| 319 | + | } | |
| 320 | + | i.confirmDialog.CancelAll("test complete") | |
| 321 | + | select { | |
| 322 | + | case <-mainResp: | |
| 323 | + | default: | |
| 324 | + | t.Fatal("main confirmation was not retained after resolving the side-chat call") | |
| 325 | + | } | |
| 326 | + | } | |
| @@ -128,8 +128,10 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||||
| 128 | Content: []provider.Content{provider.TextBlock{Text: "frozen context"}}, | 128 | Content: []provider.Content{provider.TextBlock{Text: "frozen context"}}, | ||
| 129 | }}) | 129 | }}) | ||
| 130 | var policyCalls atomic.Int32 | 130 | var policyCalls atomic.Int32 | ||
| 131 | - | main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | 131 | + | var policyOrigin string |
| 132 | + | main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 132 | policyCalls.Add(1) | 133 | policyCalls.Add(1) | ||
| 134 | + | policyOrigin = call.Origin | |||
| 133 | return true, "", nil | 135 | return true, "", nil | ||
| 134 | } | 136 | } | ||
| 135 | 137 | ||||
| @@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||||
| 155 | if got := policyCalls.Load(); got != 1 { | 157 | if got := policyCalls.Load(); got != 1 { | ||
| 156 | t.Fatalf("main agent tool-policy calls = %d, want 1", got) | 158 | t.Fatalf("main agent tool-policy calls = %d, want 1", got) | ||
| 157 | } | 159 | } | ||
| 160 | + | if policyOrigin != btwOrigin(1) { | |||
| 161 | + | t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1)) | |||
| 162 | + | } | |||
| 158 | 163 | ||||
| 159 | client.mu.Lock() | 164 | client.mu.Lock() | ||
| 160 | requests := append([]provider.Request(nil), client.requests...) | 165 | requests := append([]provider.Request(nil), client.requests...) | ||
| @@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) | |||||
| 201 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | 206 | t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3) | ||
| 202 | } | 207 | } | ||
| 203 | } | 208 | } | ||
| 209 | + | ||||
| 210 | + | func TestBtwActivityRendersFactDrivenLabel(t *testing.T) { | |||
| 211 | + | d := &btwDialog{ | |||
| 212 | + | active: true, | |||
| 213 | + | loading: true, | |||
| 214 | + | theme: tui.Dark, | |||
| 215 | + | toolView: &tui.View{Theme: tui.Dark}, | |||
| 216 | + | spin: newSpinner(tui.Dark), | |||
| 217 | + | activity: newAgentActivity("anthropic", "claude-test"), | |||
| 218 | + | } | |||
| 219 | + | d.spin.Start() | |||
| 220 | + | d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3}) | |||
| 221 | + | ||||
| 222 | + | rendered := strings.Join(d.Render(tui.Dark, 80), "\n") | |||
| 223 | + | if !strings.Contains(rendered, "Sending request to anthropic") { | |||
| 224 | + | t.Fatalf("/btw activity = %q, want factual request label", rendered) | |||
| 225 | + | } | |||
| 226 | + | if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") { | |||
| 227 | + | t.Fatalf("/btw activity retained random spinner wording: %q", rendered) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) { | |||
| 232 | + | d := &btwDialog{ | |||
| 233 | + | active: true, | |||
| 234 | + | loading: true, | |||
| 235 | + | generation: 2, | |||
| 236 | + | turns: []btwTurn{{User: "new question"}}, | |||
| 237 | + | activity: newAgentActivity("provider", "model"), | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"}) | |||
| 241 | + | d.completeTurn(0, 1, "stale error") | |||
| 242 | + | ||||
| 243 | + | d.mu.Lock() | |||
| 244 | + | defer d.mu.Unlock() | |||
| 245 | + | if d.turns[0].Assistant != "" || d.turns[0].Err != "" { | |||
| 246 | + | t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0]) | |||
| 247 | + | } | |||
| 248 | + | if !d.loading { | |||
| 249 | + | t.Fatal("stale completion cleared the reopened dialog loading state") | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) { | |||
| 254 | + | client := &btwToolTestClient{done: make(chan struct{})} | |||
| 255 | + | tool := &btwEchoTool{} | |||
| 256 | + | main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool}) | |||
| 257 | + | d := newBtwDialog() | |||
| 258 | + | i := &Interactive{ | |||
| 259 | + | btwDialog: d, | |||
| 260 | + | confirmDialog: newConfirmDialog(), | |||
| 261 | + | dirty: make(chan struct{}, 1), | |||
| 262 | + | } | |||
| 263 | + | gate := core.NewConfirmGate(i) | |||
| 264 | + | mainResp := make(chan core.ConfirmDecision, 1) | |||
| 265 | + | i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp}) | |||
| 266 | + | i.confirmDialog.Blur() | |||
| 267 | + | main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 268 | + | return gate.CheckToolCall(core.ToolCallConfirmation{ | |||
| 269 | + | ID: call.ID, | |||
| 270 | + | Name: call.Name, | |||
| 271 | + | Summary: "hello", | |||
| 272 | + | Origin: call.Origin, | |||
| 273 | + | }) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil) | |||
| 277 | + | sideConfirmationFirst := func() bool { | |||
| 278 | + | i.confirmDialog.mu.Lock() | |||
| 279 | + | defer i.confirmDialog.mu.Unlock() | |||
| 280 | + | return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo" | |||
| 281 | + | } | |||
| 282 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 283 | + | for !sideConfirmationFirst() && time.Now().Before(deadline) { | |||
| 284 | + | time.Sleep(time.Millisecond) | |||
| 285 | + | } | |||
| 286 | + | if !sideConfirmationFirst() { | |||
| 287 | + | t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue") | |||
| 288 | + | } | |||
| 289 | + | if !i.confirmDialog.Focused() { | |||
| 290 | + | t.Fatal("side-agent confirmation did not focus the prioritized dialog") | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | d.mu.Lock() | |||
| 294 | + | if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 { | |||
| 295 | + | d.mu.Unlock() | |||
| 296 | + | t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns) | |||
| 297 | + | } | |||
| 298 | + | if got, want := d.turns[0].Tools[0].Args, "hello"; got != want { | |||
| 299 | + | d.mu.Unlock() | |||
| 300 | + | t.Fatalf("side-agent confirmation summary = %q, want %q", got, want) | |||
| 301 | + | } | |||
| 302 | + | if got, want := d.activity.label(), "Waiting for approval: echo"; got != want { | |||
| 303 | + | d.mu.Unlock() | |||
| 304 | + | t.Fatalf("side-agent activity = %q, want %q", got, want) | |||
| 305 | + | } | |||
| 306 | + | d.mu.Unlock() | |||
| 307 | + | ||||
| 308 | + | i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 309 | + | select { | |||
| 310 | + | case <-client.done: | |||
| 311 | + | case <-time.After(2 * time.Second): | |||
| 312 | + | t.Fatal("side-agent did not continue after confirmation") | |||
| 313 | + | } | |||
| 314 | + | if got := tool.runs.Load(); got != 1 { | |||
| 315 | + | t.Fatalf("tool executions = %d, want 1 after confirmation", got) | |||
| 316 | + | } | |||
| 317 | + | if i.confirmDialog.Focused() { | |||
| 318 | + | t.Fatal("main confirmation regained focus instead of returning input to /btw") | |||
| 319 | + | } | |||
| 320 | + | i.confirmDialog.CancelAll("test complete") | |||
| 321 | + | select { | |||
| 322 | + | case <-mainResp: | |||
| 323 | + | default: | |||
| 324 | + | t.Fatal("main confirmation was not retained after resolving the side-chat call") | |||
| 325 | + | } | |||
| 326 | + | } | |||
M packages/agent/modes/compact_queue_test.go +0 -24
| @@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) { | |||
| 430 | 430 | } | |
| 431 | 431 | } | |
| 432 | 432 | ||
| 433 | - | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |
| 434 | - | tests := []struct { | |
| 435 | - | name string | |
| 436 | - | err error | |
| 437 | - | want bool | |
| 438 | - | }{ | |
| 439 | - | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |
| 440 | - | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 441 | - | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 442 | - | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |
| 443 | - | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |
| 444 | - | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |
| 445 | - | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | for _, tt := range tests { | |
| 449 | - | t.Run(tt.name, func(t *testing.T) { | |
| 450 | - | if got := isContextOverflowError(tt.err); got != tt.want { | |
| 451 | - | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |
| 452 | - | } | |
| 453 | - | }) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | ||
| 457 | 433 | func requestContainsUserText(req provider.Request, want string) bool { | |
| 458 | 434 | return requestUserTextCount(req, want) > 0 | |
| 459 | 435 | } | |
| @@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) { | |||||
| 430 | } | 430 | } | ||
| 431 | } | 431 | } | ||
| 432 | 432 | ||||
| 433 | - | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||
| 434 | - | tests := []struct { | |||
| 435 | - | name string | |||
| 436 | - | err error | |||
| 437 | - | want bool | |||
| 438 | - | }{ | |||
| 439 | - | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |||
| 440 | - | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |||
| 441 | - | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |||
| 442 | - | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |||
| 443 | - | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |||
| 444 | - | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |||
| 445 | - | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | for _, tt := range tests { | |||
| 449 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 450 | - | if got := isContextOverflowError(tt.err); got != tt.want { | |||
| 451 | - | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |||
| 452 | - | } | |||
| 453 | - | }) | |||
| 454 | - | } | |||
| 455 | - | } | |||
| 456 | - | ||||
| 457 | func requestContainsUserText(req provider.Request, want string) bool { | 433 | func requestContainsUserText(req provider.Request, want string) bool { | ||
| 458 | return requestUserTextCount(req, want) > 0 | 434 | return requestUserTextCount(req, want) > 0 | ||
| 459 | } | 435 | } | ||
M packages/agent/modes/confirm_dialog.go +38 -0
| @@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | // CancelChildConfirmations refuses side-chat confirmations without affecting | |
| 125 | + | // queued main-thread calls. Closing /btw must unblock its agent even though | |
| 126 | + | // the confirmation UI is no longer visible. | |
| 127 | + | func (d *confirmDialog) CancelChildConfirmations(reason string) { | |
| 128 | + | if d == nil { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | d.mu.Lock() | |
| 132 | + | pending := d.pending | |
| 133 | + | remaining := make([]*confirmRequest, 0, len(pending)) | |
| 134 | + | cancelled := make([]*confirmRequest, 0, len(pending)) | |
| 135 | + | activeCancelled := false | |
| 136 | + | for index, req := range pending { | |
| 137 | + | if req.returnToChild { | |
| 138 | + | cancelled = append(cancelled, req) | |
| 139 | + | activeCancelled = activeCancelled || index == 0 | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | remaining = append(remaining, req) | |
| 143 | + | } | |
| 144 | + | d.pending = remaining | |
| 145 | + | if activeCancelled { | |
| 146 | + | d.cursor = 0 | |
| 147 | + | d.focused = len(remaining) > 0 | |
| 148 | + | if d.focused { | |
| 149 | + | d.activeSince = time.Now() | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | d.mu.Unlock() | |
| 153 | + | ||
| 154 | + | for _, req := range cancelled { | |
| 155 | + | select { | |
| 156 | + | case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}: | |
| 157 | + | default: | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 124 | 162 | // AllowAllPending approves every pending request and drains the | |
| 125 | 163 | // queue. Used by /yolo so any confirmation dialog already on screen | |
| 126 | 164 | // resolves immediately as "yes". | |
| @@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) { | |||||
| 121 | } | 121 | } | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | // CancelChildConfirmations refuses side-chat confirmations without affecting | |||
| 125 | + | // queued main-thread calls. Closing /btw must unblock its agent even though | |||
| 126 | + | // the confirmation UI is no longer visible. | |||
| 127 | + | func (d *confirmDialog) CancelChildConfirmations(reason string) { | |||
| 128 | + | if d == nil { | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | d.mu.Lock() | |||
| 132 | + | pending := d.pending | |||
| 133 | + | remaining := make([]*confirmRequest, 0, len(pending)) | |||
| 134 | + | cancelled := make([]*confirmRequest, 0, len(pending)) | |||
| 135 | + | activeCancelled := false | |||
| 136 | + | for index, req := range pending { | |||
| 137 | + | if req.returnToChild { | |||
| 138 | + | cancelled = append(cancelled, req) | |||
| 139 | + | activeCancelled = activeCancelled || index == 0 | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 142 | + | remaining = append(remaining, req) | |||
| 143 | + | } | |||
| 144 | + | d.pending = remaining | |||
| 145 | + | if activeCancelled { | |||
| 146 | + | d.cursor = 0 | |||
| 147 | + | d.focused = len(remaining) > 0 | |||
| 148 | + | if d.focused { | |||
| 149 | + | d.activeSince = time.Now() | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | d.mu.Unlock() | |||
| 153 | + | ||||
| 154 | + | for _, req := range cancelled { | |||
| 155 | + | select { | |||
| 156 | + | case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}: | |||
| 157 | + | default: | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 124 | // AllowAllPending approves every pending request and drains the | 162 | // AllowAllPending approves every pending request and drains the | ||
| 125 | // queue. Used by /yolo so any confirmation dialog already on screen | 163 | // queue. Used by /yolo so any confirmation dialog already on screen | ||
| 126 | // resolves immediately as "yes". | 164 | // resolves immediately as "yes". | ||
M packages/agent/modes/confirm_dialog_test.go +65 -3
| @@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||
| 39 | 39 | Name: "edit", | |
| 40 | 40 | Summary: "sample.go", | |
| 41 | 41 | Content: "-old\n+new\n", | |
| 42 | + | Origin: btwOrigin(0), | |
| 42 | 43 | }) | |
| 43 | 44 | }() | |
| 44 | 45 | ||
| @@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||
| 50 | 51 | t.Fatal("confirmation dialog did not open") | |
| 51 | 52 | } | |
| 52 | 53 | i.mu.Lock() | |
| 53 | - | preview := i.toolCalls["call-1"].Preview | |
| 54 | + | mainPreview := i.toolCalls["call-1"].Preview | |
| 54 | 55 | i.mu.Unlock() | |
| 55 | - | if preview != "-old\n+new\n" { | |
| 56 | - | t.Fatalf("tool preview = %q", preview) | |
| 56 | + | if mainPreview != "" { | |
| 57 | + | t.Fatalf("main tool preview = %q, want side-chat origin to leave it untouched", mainPreview) | |
| 57 | 58 | } | |
| 58 | 59 | btw.mu.Lock() | |
| 59 | 60 | btwPreview := btw.turns[0].Tools[0].Preview | |
| @@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||
| 79 | 80 | } | |
| 80 | 81 | } | |
| 81 | 82 | ||
| 83 | + | func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) { | |
| 84 | + | i := newConfirmationInputInteractive() | |
| 85 | + | i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil) | |
| 86 | + | i.btwDialog.mu.Lock() | |
| 87 | + | origin := btwOrigin(i.btwDialog.generation) | |
| 88 | + | i.btwDialog.mu.Unlock() | |
| 89 | + | i.btwDialog.Close() | |
| 90 | + | ||
| 91 | + | decision := i.ConfirmToolCall(core.ToolCallConfirmation{ | |
| 92 | + | ID: "call-1", | |
| 93 | + | Name: "edit", | |
| 94 | + | Origin: origin, | |
| 95 | + | }) | |
| 96 | + | if decision.Allow || decision.Reason != "side-chat tool call canceled" { | |
| 97 | + | t.Fatalf("stale side-chat decision = %+v", decision) | |
| 98 | + | } | |
| 99 | + | if i.confirmDialog.Active() { | |
| 100 | + | t.Fatal("stale side-chat confirmation activated the main dialog") | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) { | |
| 105 | + | i := newConfirmationInputInteractive() | |
| 106 | + | d := i.btwDialog | |
| 107 | + | d.mu.Lock() | |
| 108 | + | d.active = true | |
| 109 | + | d.generation = 1 | |
| 110 | + | d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}} | |
| 111 | + | d.mu.Unlock() | |
| 112 | + | ||
| 113 | + | decision := make(chan core.ConfirmDecision, 1) | |
| 114 | + | go func() { | |
| 115 | + | decision <- i.ConfirmToolCall(core.ToolCallConfirmation{ | |
| 116 | + | ID: "call-1", | |
| 117 | + | Name: "edit", | |
| 118 | + | Origin: btwOrigin(1), | |
| 119 | + | }) | |
| 120 | + | }() | |
| 121 | + | ||
| 122 | + | deadline := time.Now().Add(time.Second) | |
| 123 | + | for !i.confirmDialog.Active() && time.Now().Before(deadline) { | |
| 124 | + | runtime.Gosched() | |
| 125 | + | } | |
| 126 | + | if !i.confirmDialog.Active() { | |
| 127 | + | t.Fatal("side-chat confirmation did not enter the queue") | |
| 128 | + | } | |
| 129 | + | d.Close() | |
| 130 | + | ||
| 131 | + | select { | |
| 132 | + | case got := <-decision: | |
| 133 | + | if got.Allow || got.Reason != "side chat closed" { | |
| 134 | + | t.Fatalf("close decision = %+v", got) | |
| 135 | + | } | |
| 136 | + | case <-time.After(time.Second): | |
| 137 | + | t.Fatal("closing /btw did not unblock its confirmation") | |
| 138 | + | } | |
| 139 | + | if i.confirmDialog.Active() { | |
| 140 | + | t.Fatal("side-chat confirmation remained active after /btw closed") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 82 | 144 | func newConfirmationInputInteractive() *Interactive { | |
| 83 | 145 | agent := core.NewAgent(nil, "test-model", "test system", nil) | |
| 84 | 146 | return NewInteractive(InteractiveConfig{ | |
| @@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||||
| 39 | Name: "edit", | 39 | Name: "edit", | ||
| 40 | Summary: "sample.go", | 40 | Summary: "sample.go", | ||
| 41 | Content: "-old\n+new\n", | 41 | Content: "-old\n+new\n", | ||
| 42 | + | Origin: btwOrigin(0), | |||
| 42 | }) | 43 | }) | ||
| 43 | }() | 44 | }() | ||
| 44 | 45 | ||||
| @@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||||
| 50 | t.Fatal("confirmation dialog did not open") | 51 | t.Fatal("confirmation dialog did not open") | ||
| 51 | } | 52 | } | ||
| 52 | i.mu.Lock() | 53 | i.mu.Lock() | ||
| 53 | - | preview := i.toolCalls["call-1"].Preview | 54 | + | mainPreview := i.toolCalls["call-1"].Preview |
| 54 | i.mu.Unlock() | 55 | i.mu.Unlock() | ||
| 55 | - | if preview != "-old\n+new\n" { | 56 | + | if mainPreview != "" { |
| 56 | - | t.Fatalf("tool preview = %q", preview) | 57 | + | t.Fatalf("main tool preview = %q, want side-chat origin to leave it untouched", mainPreview) |
| 57 | } | 58 | } | ||
| 58 | btw.mu.Lock() | 59 | btw.mu.Lock() | ||
| 59 | btwPreview := btw.turns[0].Tools[0].Preview | 60 | btwPreview := btw.turns[0].Tools[0].Preview | ||
| @@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||||
| 79 | } | 80 | } | ||
| 80 | } | 81 | } | ||
| 81 | 82 | ||||
| 83 | + | func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) { | |||
| 84 | + | i := newConfirmationInputInteractive() | |||
| 85 | + | i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil) | |||
| 86 | + | i.btwDialog.mu.Lock() | |||
| 87 | + | origin := btwOrigin(i.btwDialog.generation) | |||
| 88 | + | i.btwDialog.mu.Unlock() | |||
| 89 | + | i.btwDialog.Close() | |||
| 90 | + | ||||
| 91 | + | decision := i.ConfirmToolCall(core.ToolCallConfirmation{ | |||
| 92 | + | ID: "call-1", | |||
| 93 | + | Name: "edit", | |||
| 94 | + | Origin: origin, | |||
| 95 | + | }) | |||
| 96 | + | if decision.Allow || decision.Reason != "side-chat tool call canceled" { | |||
| 97 | + | t.Fatalf("stale side-chat decision = %+v", decision) | |||
| 98 | + | } | |||
| 99 | + | if i.confirmDialog.Active() { | |||
| 100 | + | t.Fatal("stale side-chat confirmation activated the main dialog") | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) { | |||
| 105 | + | i := newConfirmationInputInteractive() | |||
| 106 | + | d := i.btwDialog | |||
| 107 | + | d.mu.Lock() | |||
| 108 | + | d.active = true | |||
| 109 | + | d.generation = 1 | |||
| 110 | + | d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}} | |||
| 111 | + | d.mu.Unlock() | |||
| 112 | + | ||||
| 113 | + | decision := make(chan core.ConfirmDecision, 1) | |||
| 114 | + | go func() { | |||
| 115 | + | decision <- i.ConfirmToolCall(core.ToolCallConfirmation{ | |||
| 116 | + | ID: "call-1", | |||
| 117 | + | Name: "edit", | |||
| 118 | + | Origin: btwOrigin(1), | |||
| 119 | + | }) | |||
| 120 | + | }() | |||
| 121 | + | ||||
| 122 | + | deadline := time.Now().Add(time.Second) | |||
| 123 | + | for !i.confirmDialog.Active() && time.Now().Before(deadline) { | |||
| 124 | + | runtime.Gosched() | |||
| 125 | + | } | |||
| 126 | + | if !i.confirmDialog.Active() { | |||
| 127 | + | t.Fatal("side-chat confirmation did not enter the queue") | |||
| 128 | + | } | |||
| 129 | + | d.Close() | |||
| 130 | + | ||||
| 131 | + | select { | |||
| 132 | + | case got := <-decision: | |||
| 133 | + | if got.Allow || got.Reason != "side chat closed" { | |||
| 134 | + | t.Fatalf("close decision = %+v", got) | |||
| 135 | + | } | |||
| 136 | + | case <-time.After(time.Second): | |||
| 137 | + | t.Fatal("closing /btw did not unblock its confirmation") | |||
| 138 | + | } | |||
| 139 | + | if i.confirmDialog.Active() { | |||
| 140 | + | t.Fatal("side-chat confirmation remained active after /btw closed") | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 82 | func newConfirmationInputInteractive() *Interactive { | 144 | func newConfirmationInputInteractive() *Interactive { | ||
| 83 | agent := core.NewAgent(nil, "test-model", "test system", nil) | 145 | agent := core.NewAgent(nil, "test-model", "test system", nil) | ||
| 84 | return NewInteractive(InteractiveConfig{ | 146 | return NewInteractive(InteractiveConfig{ | ||
M packages/agent/modes/interactive.go +42 -42
| @@ -529,6 +529,7 @@ type Interactive struct { | |||
| 529 | 529 | cumUsage provider.Usage | |
| 530 | 530 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 531 | 531 | busy bool | |
| 532 | + | activity agentActivity | |
| 532 | 533 | subagentActivityActive bool | |
| 533 | 534 | pendingIdleWork []func() | |
| 534 | 535 | dirty chan struct{} | |
| @@ -813,6 +814,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 813 | 814 | clock: time.Now, | |
| 814 | 815 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 815 | 816 | } | |
| 817 | + | i.btwDialog.setCloseHook(func() { | |
| 818 | + | i.confirmDialog.CancelChildConfirmations("side chat closed") | |
| 819 | + | }) | |
| 816 | 820 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 817 | 821 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 818 | 822 | if cfg.LlamaCPPConfig != nil { | |
| @@ -1773,16 +1777,17 @@ func (i *Interactive) redraw() { | |||
| 1773 | 1777 | suggest = append([]string{""}, suggest...) | |
| 1774 | 1778 | } | |
| 1775 | 1779 | ||
| 1776 | - | // Busy prefix shown at the far left of the status bar. The | |
| 1777 | - | // spinner glyph and its funny-line message share the `zut` | |
| 1778 | - | // label colour (Theme.Assistant) so the whole "who's working" | |
| 1779 | - | // band reads at a glance. Elapsed time stays muted because it | |
| 1780 | - | // drifts every second and shouldn't grab focus. | |
| 1780 | + | // Busy prefix shown at the far left of the status bar. The spinner owns | |
| 1781 | + | // only its frame; the activity label reports the operation in progress. | |
| 1781 | 1782 | var busyPrefix string | |
| 1782 | 1783 | if i.busy { | |
| 1784 | + | label := i.activity.label() | |
| 1785 | + | if label == "" { | |
| 1786 | + | label = "Preparing request" | |
| 1787 | + | } | |
| 1783 | 1788 | busyPrefix = fmt.Sprintf("%s %s %s %s", | |
| 1784 | 1789 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | |
| 1785 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()), | |
| 1790 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), | |
| 1786 | 1791 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | |
| 1787 | 1792 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 1788 | 1793 | ) | |
| @@ -5508,7 +5513,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5508 | 5513 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 5509 | 5514 | i.mu.Lock() | |
| 5510 | 5515 | i.modelRefreshing = true | |
| 5511 | - | i.statusOK = "refreshing models" | |
| 5516 | + | i.statusOK = "Refreshing models" | |
| 5512 | 5517 | i.statusErr = "" | |
| 5513 | 5518 | i.mu.Unlock() | |
| 5514 | 5519 | go func() { | |
| @@ -5707,7 +5712,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 5707 | 5712 | if refreshErr != nil { | |
| 5708 | 5713 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 5709 | 5714 | i.statusOK = "" | |
| 5710 | - | } else if i.statusOK == "refreshing models" { | |
| 5715 | + | } else if i.statusOK == "Refreshing models" { | |
| 5711 | 5716 | i.statusOK = "" | |
| 5712 | 5717 | } | |
| 5713 | 5718 | i.mu.Unlock() | |
| @@ -6533,11 +6538,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6533 | 6538 | i.mu.Lock() | |
| 6534 | 6539 | i.busy = true | |
| 6535 | 6540 | i.compacting = true | |
| 6541 | + | i.spin.Start() | |
| 6542 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 6536 | 6543 | if auto { | |
| 6537 | - | i.spin.StartFixed("condensing history") | |
| 6544 | + | i.activity.kind = activityCondensingHistory | |
| 6538 | 6545 | i.autoCompacting = true | |
| 6539 | 6546 | } else { | |
| 6540 | - | i.spin.StartFixed("compacting") | |
| 6547 | + | i.activity.kind = activityCompactingHistory | |
| 6541 | 6548 | } | |
| 6542 | 6549 | i.cancelTurn = cancel | |
| 6543 | 6550 | i.statusErr = "" | |
| @@ -6733,7 +6740,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 6733 | 6740 | i.cancelTurn = cancel | |
| 6734 | 6741 | i.statusErr = "" | |
| 6735 | 6742 | i.statusOK = "" | |
| 6736 | - | i.spin.StartFixed("running shell command") | |
| 6743 | + | i.spin.Start() | |
| 6744 | + | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |
| 6737 | 6745 | // Clear stale extension notes the same way a new turn would so the | |
| 6738 | 6746 | // screen doesn't accumulate transient state. | |
| 6739 | 6747 | i.extNotes = nil | |
| @@ -6866,6 +6874,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6866 | 6874 | i.mu.Lock() | |
| 6867 | 6875 | i.busy = true | |
| 6868 | 6876 | i.spin.Start() | |
| 6877 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 6869 | 6878 | i.cancelTurn = cancel | |
| 6870 | 6879 | i.statusErr = "" | |
| 6871 | 6880 | i.statusOK = "" | |
| @@ -6974,7 +6983,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6974 | 6983 | // based auto-compact can miss both when metadata is stale or the | |
| 6975 | 6984 | // limit is measured in raw bytes. Compact once, then continue the | |
| 6976 | 6985 | // user message already present in the transcript. | |
| 6977 | - | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | |
| 6986 | + | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) | |
| 6978 | 6987 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | |
| 6979 | 6988 | if recoverContextOverflow { | |
| 6980 | 6989 | i.statusErr = "" | |
| @@ -7148,27 +7157,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||
| 7148 | 7157 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | |
| 7149 | 7158 | } | |
| 7150 | 7159 | ||
| 7151 | - | // isContextOverflowError matches provider responses that reject the | |
| 7152 | - | // current request because its raw payload or tokenized input is too | |
| 7153 | - | // large. Providers surface this as either HTTP 413 or semantic context | |
| 7154 | - | // errors with differing codes and messages. | |
| 7155 | - | func isContextOverflowError(err error) bool { | |
| 7156 | - | if err == nil { | |
| 7157 | - | return false | |
| 7158 | - | } | |
| 7159 | - | msg := strings.ToLower(err.Error()) | |
| 7160 | - | return strings.Contains(msg, "http 413") || | |
| 7161 | - | strings.Contains(msg, " 413") || | |
| 7162 | - | strings.HasPrefix(msg, "413 ") || | |
| 7163 | - | strings.Contains(msg, "payload too large") || | |
| 7164 | - | strings.Contains(msg, "request entity too large") || | |
| 7165 | - | strings.Contains(msg, "input exceeds the context window") || | |
| 7166 | - | strings.Contains(msg, "context window exceeded") || | |
| 7167 | - | strings.Contains(msg, "maximum context length") || | |
| 7168 | - | strings.Contains(msg, "context_length_exceeded") || | |
| 7169 | - | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |
| 7170 | - | } | |
| 7171 | - | ||
| 7172 | 7160 | const defaultAutoCompactThreshold = 85 | |
| 7173 | 7161 | ||
| 7174 | 7162 | func normalizeAutoCompactThreshold(threshold *int) int { | |
| @@ -7241,6 +7229,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||
| 7241 | 7229 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 7242 | 7230 | i.mu.Lock() | |
| 7243 | 7231 | defer i.mu.Unlock() | |
| 7232 | + | i.activity.apply(ev) | |
| 7244 | 7233 | switch e := ev.(type) { | |
| 7245 | 7234 | case core.EvAssistantStart: | |
| 7246 | 7235 | // Fires at the top of every oneTurn, including follow-up | |
| @@ -7463,7 +7452,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 7463 | 7452 | return | |
| 7464 | 7453 | } | |
| 7465 | 7454 | i.mu.Lock() | |
| 7466 | - | i.statusOK = "reloading extensions..." | |
| 7455 | + | i.statusOK = "Reloading extensions..." | |
| 7467 | 7456 | i.statusErr = "" | |
| 7468 | 7457 | i.mu.Unlock() | |
| 7469 | 7458 | i.invalidate() | |
| @@ -7493,23 +7482,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||
| 7493 | 7482 | // tool panel, then blocks until the user approves or refuses the call. | |
| 7494 | 7483 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 7495 | 7484 | resp := make(chan core.ConfirmDecision, 1) | |
| 7496 | - | returnToChild := false | |
| 7485 | + | if isBtwOrigin(call.Origin) { | |
| 7486 | + | req := &confirmRequest{ | |
| 7487 | + | toolName: call.Name, | |
| 7488 | + | preview: call.Summary, | |
| 7489 | + | resp: resp, | |
| 7490 | + | returnToChild: true, | |
| 7491 | + | } | |
| 7492 | + | if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() { | |
| 7493 | + | i.confirmDialog.Enqueue(req) | |
| 7494 | + | }) { | |
| 7495 | + | return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"} | |
| 7496 | + | } | |
| 7497 | + | i.invalidate() | |
| 7498 | + | return <-resp | |
| 7499 | + | } | |
| 7497 | 7500 | if call.ID != "" { | |
| 7498 | 7501 | i.mu.Lock() | |
| 7499 | 7502 | if tc, ok := i.toolCalls[call.ID]; ok { | |
| 7500 | 7503 | tc.Args = call.Summary | |
| 7501 | 7504 | tc.Preview = call.Content | |
| 7505 | + | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |
| 7502 | 7506 | } | |
| 7503 | 7507 | i.mu.Unlock() | |
| 7504 | - | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 7505 | - | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |
| 7506 | - | } | |
| 7507 | 7508 | } | |
| 7508 | 7509 | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 7509 | - | toolName: call.Name, | |
| 7510 | - | preview: call.Summary, | |
| 7511 | - | resp: resp, | |
| 7512 | - | returnToChild: returnToChild, | |
| 7510 | + | toolName: call.Name, | |
| 7511 | + | preview: call.Summary, | |
| 7512 | + | resp: resp, | |
| 7513 | 7513 | }) | |
| 7514 | 7514 | i.invalidate() | |
| 7515 | 7515 | return <-resp | |
| @@ -529,6 +529,7 @@ type Interactive struct { | |||||
| 529 | cumUsage provider.Usage | 529 | cumUsage provider.Usage | ||
| 530 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 530 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 531 | busy bool | 531 | busy bool | ||
| 532 | + | activity agentActivity | |||
| 532 | subagentActivityActive bool | 533 | subagentActivityActive bool | ||
| 533 | pendingIdleWork []func() | 534 | pendingIdleWork []func() | ||
| 534 | dirty chan struct{} | 535 | dirty chan struct{} | ||
| @@ -813,6 +814,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 813 | clock: time.Now, | 814 | clock: time.Now, | ||
| 814 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | 815 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | ||
| 815 | } | 816 | } | ||
| 817 | + | i.btwDialog.setCloseHook(func() { | |||
| 818 | + | i.confirmDialog.CancelChildConfirmations("side chat closed") | |||
| 819 | + | }) | |||
| 816 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 820 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 817 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | 821 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | ||
| 818 | if cfg.LlamaCPPConfig != nil { | 822 | if cfg.LlamaCPPConfig != nil { | ||
| @@ -1773,16 +1777,17 @@ func (i *Interactive) redraw() { | |||||
| 1773 | suggest = append([]string{""}, suggest...) | 1777 | suggest = append([]string{""}, suggest...) | ||
| 1774 | } | 1778 | } | ||
| 1775 | 1779 | ||||
| 1776 | - | // Busy prefix shown at the far left of the status bar. The | 1780 | + | // Busy prefix shown at the far left of the status bar. The spinner owns |
| 1777 | - | // spinner glyph and its funny-line message share the `zut` | 1781 | + | // only its frame; the activity label reports the operation in progress. |
| 1778 | - | // label colour (Theme.Assistant) so the whole "who's working" | |||
| 1779 | - | // band reads at a glance. Elapsed time stays muted because it | |||
| 1780 | - | // drifts every second and shouldn't grab focus. | |||
| 1781 | var busyPrefix string | 1782 | var busyPrefix string | ||
| 1782 | if i.busy { | 1783 | if i.busy { | ||
| 1784 | + | label := i.activity.label() | |||
| 1785 | + | if label == "" { | |||
| 1786 | + | label = "Preparing request" | |||
| 1787 | + | } | |||
| 1783 | busyPrefix = fmt.Sprintf("%s %s %s %s", | 1788 | busyPrefix = fmt.Sprintf("%s %s %s %s", | ||
| 1784 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | 1789 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | ||
| 1785 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()), | 1790 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), |
| 1786 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | 1791 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | ||
| 1787 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | 1792 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | ||
| 1788 | ) | 1793 | ) | ||
| @@ -5508,7 +5513,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5508 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | 5513 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | ||
| 5509 | i.mu.Lock() | 5514 | i.mu.Lock() | ||
| 5510 | i.modelRefreshing = true | 5515 | i.modelRefreshing = true | ||
| 5511 | - | i.statusOK = "refreshing models" | 5516 | + | i.statusOK = "Refreshing models" |
| 5512 | i.statusErr = "" | 5517 | i.statusErr = "" | ||
| 5513 | i.mu.Unlock() | 5518 | i.mu.Unlock() | ||
| 5514 | go func() { | 5519 | go func() { | ||
| @@ -5707,7 +5712,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||||
| 5707 | if refreshErr != nil { | 5712 | if refreshErr != nil { | ||
| 5708 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | 5713 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | ||
| 5709 | i.statusOK = "" | 5714 | i.statusOK = "" | ||
| 5710 | - | } else if i.statusOK == "refreshing models" { | 5715 | + | } else if i.statusOK == "Refreshing models" { |
| 5711 | i.statusOK = "" | 5716 | i.statusOK = "" | ||
| 5712 | } | 5717 | } | ||
| 5713 | i.mu.Unlock() | 5718 | i.mu.Unlock() | ||
| @@ -6533,11 +6538,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6533 | i.mu.Lock() | 6538 | i.mu.Lock() | ||
| 6534 | i.busy = true | 6539 | i.busy = true | ||
| 6535 | i.compacting = true | 6540 | i.compacting = true | ||
| 6541 | + | i.spin.Start() | |||
| 6542 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |||
| 6536 | if auto { | 6543 | if auto { | ||
| 6537 | - | i.spin.StartFixed("condensing history") | 6544 | + | i.activity.kind = activityCondensingHistory |
| 6538 | i.autoCompacting = true | 6545 | i.autoCompacting = true | ||
| 6539 | } else { | 6546 | } else { | ||
| 6540 | - | i.spin.StartFixed("compacting") | 6547 | + | i.activity.kind = activityCompactingHistory |
| 6541 | } | 6548 | } | ||
| 6542 | i.cancelTurn = cancel | 6549 | i.cancelTurn = cancel | ||
| 6543 | i.statusErr = "" | 6550 | i.statusErr = "" | ||
| @@ -6733,7 +6740,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 6733 | i.cancelTurn = cancel | 6740 | i.cancelTurn = cancel | ||
| 6734 | i.statusErr = "" | 6741 | i.statusErr = "" | ||
| 6735 | i.statusOK = "" | 6742 | i.statusOK = "" | ||
| 6736 | - | i.spin.StartFixed("running shell command") | 6743 | + | i.spin.Start() |
| 6744 | + | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |||
| 6737 | // Clear stale extension notes the same way a new turn would so the | 6745 | // Clear stale extension notes the same way a new turn would so the | ||
| 6738 | // screen doesn't accumulate transient state. | 6746 | // screen doesn't accumulate transient state. | ||
| 6739 | i.extNotes = nil | 6747 | i.extNotes = nil | ||
| @@ -6866,6 +6874,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6866 | i.mu.Lock() | 6874 | i.mu.Lock() | ||
| 6867 | i.busy = true | 6875 | i.busy = true | ||
| 6868 | i.spin.Start() | 6876 | i.spin.Start() | ||
| 6877 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |||
| 6869 | i.cancelTurn = cancel | 6878 | i.cancelTurn = cancel | ||
| 6870 | i.statusErr = "" | 6879 | i.statusErr = "" | ||
| 6871 | i.statusOK = "" | 6880 | i.statusOK = "" | ||
| @@ -6974,7 +6983,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6974 | // based auto-compact can miss both when metadata is stale or the | 6983 | // based auto-compact can miss both when metadata is stale or the | ||
| 6975 | // limit is measured in raw bytes. Compact once, then continue the | 6984 | // limit is measured in raw bytes. Compact once, then continue the | ||
| 6976 | // user message already present in the transcript. | 6985 | // user message already present in the transcript. | ||
| 6977 | - | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | 6986 | + | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) |
| 6978 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | 6987 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | ||
| 6979 | if recoverContextOverflow { | 6988 | if recoverContextOverflow { | ||
| 6980 | i.statusErr = "" | 6989 | i.statusErr = "" | ||
| @@ -7148,27 +7157,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||||
| 7148 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | 7157 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | ||
| 7149 | } | 7158 | } | ||
| 7150 | 7159 | ||||
| 7151 | - | // isContextOverflowError matches provider responses that reject the | |||
| 7152 | - | // current request because its raw payload or tokenized input is too | |||
| 7153 | - | // large. Providers surface this as either HTTP 413 or semantic context | |||
| 7154 | - | // errors with differing codes and messages. | |||
| 7155 | - | func isContextOverflowError(err error) bool { | |||
| 7156 | - | if err == nil { | |||
| 7157 | - | return false | |||
| 7158 | - | } | |||
| 7159 | - | msg := strings.ToLower(err.Error()) | |||
| 7160 | - | return strings.Contains(msg, "http 413") || | |||
| 7161 | - | strings.Contains(msg, " 413") || | |||
| 7162 | - | strings.HasPrefix(msg, "413 ") || | |||
| 7163 | - | strings.Contains(msg, "payload too large") || | |||
| 7164 | - | strings.Contains(msg, "request entity too large") || | |||
| 7165 | - | strings.Contains(msg, "input exceeds the context window") || | |||
| 7166 | - | strings.Contains(msg, "context window exceeded") || | |||
| 7167 | - | strings.Contains(msg, "maximum context length") || | |||
| 7168 | - | strings.Contains(msg, "context_length_exceeded") || | |||
| 7169 | - | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |||
| 7170 | - | } | |||
| 7171 | - | ||||
| 7172 | const defaultAutoCompactThreshold = 85 | 7160 | const defaultAutoCompactThreshold = 85 | ||
| 7173 | 7161 | ||||
| 7174 | func normalizeAutoCompactThreshold(threshold *int) int { | 7162 | func normalizeAutoCompactThreshold(threshold *int) int { | ||
| @@ -7241,6 +7229,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||||
| 7241 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | 7229 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | ||
| 7242 | i.mu.Lock() | 7230 | i.mu.Lock() | ||
| 7243 | defer i.mu.Unlock() | 7231 | defer i.mu.Unlock() | ||
| 7232 | + | i.activity.apply(ev) | |||
| 7244 | switch e := ev.(type) { | 7233 | switch e := ev.(type) { | ||
| 7245 | case core.EvAssistantStart: | 7234 | case core.EvAssistantStart: | ||
| 7246 | // Fires at the top of every oneTurn, including follow-up | 7235 | // Fires at the top of every oneTurn, including follow-up | ||
| @@ -7463,7 +7452,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||||
| 7463 | return | 7452 | return | ||
| 7464 | } | 7453 | } | ||
| 7465 | i.mu.Lock() | 7454 | i.mu.Lock() | ||
| 7466 | - | i.statusOK = "reloading extensions..." | 7455 | + | i.statusOK = "Reloading extensions..." |
| 7467 | i.statusErr = "" | 7456 | i.statusErr = "" | ||
| 7468 | i.mu.Unlock() | 7457 | i.mu.Unlock() | ||
| 7469 | i.invalidate() | 7458 | i.invalidate() | ||
| @@ -7493,23 +7482,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||||
| 7493 | // tool panel, then blocks until the user approves or refuses the call. | 7482 | // tool panel, then blocks until the user approves or refuses the call. | ||
| 7494 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | 7483 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | ||
| 7495 | resp := make(chan core.ConfirmDecision, 1) | 7484 | resp := make(chan core.ConfirmDecision, 1) | ||
| 7496 | - | returnToChild := false | 7485 | + | if isBtwOrigin(call.Origin) { |
| 7486 | + | req := &confirmRequest{ | |||
| 7487 | + | toolName: call.Name, | |||
| 7488 | + | preview: call.Summary, | |||
| 7489 | + | resp: resp, | |||
| 7490 | + | returnToChild: true, | |||
| 7491 | + | } | |||
| 7492 | + | if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() { | |||
| 7493 | + | i.confirmDialog.Enqueue(req) | |||
| 7494 | + | }) { | |||
| 7495 | + | return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"} | |||
| 7496 | + | } | |||
| 7497 | + | i.invalidate() | |||
| 7498 | + | return <-resp | |||
| 7499 | + | } | |||
| 7497 | if call.ID != "" { | 7500 | if call.ID != "" { | ||
| 7498 | i.mu.Lock() | 7501 | i.mu.Lock() | ||
| 7499 | if tc, ok := i.toolCalls[call.ID]; ok { | 7502 | if tc, ok := i.toolCalls[call.ID]; ok { | ||
| 7500 | tc.Args = call.Summary | 7503 | tc.Args = call.Summary | ||
| 7501 | tc.Preview = call.Content | 7504 | tc.Preview = call.Content | ||
| 7505 | + | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |||
| 7502 | } | 7506 | } | ||
| 7503 | i.mu.Unlock() | 7507 | i.mu.Unlock() | ||
| 7504 | - | if i.btwDialog != nil && i.btwDialog.Active() { | |||
| 7505 | - | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |||
| 7506 | - | } | |||
| 7507 | } | 7508 | } | ||
| 7508 | i.confirmDialog.Enqueue(&confirmRequest{ | 7509 | i.confirmDialog.Enqueue(&confirmRequest{ | ||
| 7509 | - | toolName: call.Name, | 7510 | + | toolName: call.Name, |
| 7510 | - | preview: call.Summary, | 7511 | + | preview: call.Summary, |
| 7511 | - | resp: resp, | 7512 | + | resp: resp, |
| 7512 | - | returnToChild: returnToChild, | |||
| 7513 | }) | 7513 | }) | ||
| 7514 | i.invalidate() | 7514 | i.invalidate() | ||
| 7515 | return <-resp | 7515 | return <-resp | ||
M packages/agent/modes/interactive_model_test.go +1 -1
| @@ -105,7 +105,7 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |||
| 105 | 105 | if !i.modelDialog.Active() { | |
| 106 | 106 | t.Fatal("model picker did not open immediately") | |
| 107 | 107 | } | |
| 108 | - | if i.statusOK == "refreshing models" { | |
| 108 | + | if i.statusOK == "Refreshing models" { | |
| 109 | 109 | t.Fatal("refresh status shown without llama.cpp router configuration") | |
| 110 | 110 | } | |
| 111 | 111 | } | |
| @@ -105,7 +105,7 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |||||
| 105 | if !i.modelDialog.Active() { | 105 | if !i.modelDialog.Active() { | ||
| 106 | t.Fatal("model picker did not open immediately") | 106 | t.Fatal("model picker did not open immediately") | ||
| 107 | } | 107 | } | ||
| 108 | - | if i.statusOK == "refreshing models" { | 108 | + | if i.statusOK == "Refreshing models" { |
| 109 | t.Fatal("refresh status shown without llama.cpp router configuration") | 109 | t.Fatal("refresh status shown without llama.cpp router configuration") | ||
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
M packages/agent/modes/json.go +14 -0
| @@ -41,6 +41,17 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 41 | 41 | switch e := ev.(type) { | |
| 42 | 42 | case core.EvTurnStart: | |
| 43 | 43 | m["step"] = e.Step | |
| 44 | + | case core.EvRequestStarted: | |
| 45 | + | m["provider"] = e.Provider | |
| 46 | + | m["model"] = e.Model | |
| 47 | + | m["scope"] = string(e.Scope) | |
| 48 | + | m["attempt"] = e.Attempt | |
| 49 | + | m["max_attempts"] = e.MaxAttempts | |
| 50 | + | case core.EvRetryScheduled: | |
| 51 | + | m["scope"] = string(e.Scope) | |
| 52 | + | m["attempt"] = e.Attempt | |
| 53 | + | m["max_attempts"] = e.MaxAttempts | |
| 54 | + | m["delay_ms"] = e.Delay.Milliseconds() | |
| 44 | 55 | case core.EvUserMessage: | |
| 45 | 56 | m["content"] = ContentToJSON(e.Message.Content) | |
| 46 | 57 | m["time"] = e.Message.Time | |
| @@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||
| 63 | 74 | var args any | |
| 64 | 75 | _ = json.Unmarshal(e.Args, &args) | |
| 65 | 76 | m["args"] = args | |
| 77 | + | case core.EvToolExecutionStarted: | |
| 78 | + | m["id"] = e.ID | |
| 79 | + | m["name"] = e.Name | |
| 66 | 80 | case core.EvToolProgress: | |
| 67 | 81 | m["id"] = e.ID | |
| 68 | 82 | m["text"] = e.Text | |
| @@ -41,6 +41,17 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 41 | switch e := ev.(type) { | 41 | switch e := ev.(type) { | ||
| 42 | case core.EvTurnStart: | 42 | case core.EvTurnStart: | ||
| 43 | m["step"] = e.Step | 43 | m["step"] = e.Step | ||
| 44 | + | case core.EvRequestStarted: | |||
| 45 | + | m["provider"] = e.Provider | |||
| 46 | + | m["model"] = e.Model | |||
| 47 | + | m["scope"] = string(e.Scope) | |||
| 48 | + | m["attempt"] = e.Attempt | |||
| 49 | + | m["max_attempts"] = e.MaxAttempts | |||
| 50 | + | case core.EvRetryScheduled: | |||
| 51 | + | m["scope"] = string(e.Scope) | |||
| 52 | + | m["attempt"] = e.Attempt | |||
| 53 | + | m["max_attempts"] = e.MaxAttempts | |||
| 54 | + | m["delay_ms"] = e.Delay.Milliseconds() | |||
| 44 | case core.EvUserMessage: | 55 | case core.EvUserMessage: | ||
| 45 | m["content"] = ContentToJSON(e.Message.Content) | 56 | m["content"] = ContentToJSON(e.Message.Content) | ||
| 46 | m["time"] = e.Message.Time | 57 | m["time"] = e.Message.Time | ||
| @@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 63 | var args any | 74 | var args any | ||
| 64 | _ = json.Unmarshal(e.Args, &args) | 75 | _ = json.Unmarshal(e.Args, &args) | ||
| 65 | m["args"] = args | 76 | m["args"] = args | ||
| 77 | + | case core.EvToolExecutionStarted: | |||
| 78 | + | m["id"] = e.ID | |||
| 79 | + | m["name"] = e.Name | |||
| 66 | case core.EvToolProgress: | 80 | case core.EvToolProgress: | ||
| 67 | m["id"] = e.ID | 81 | m["id"] = e.ID | ||
| 68 | m["text"] = e.Text | 82 | m["text"] = e.Text | ||
A packages/agent/modes/json_activity_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestEventToJSONActivityLifecycle(t *testing.T) { | |
| 11 | + | request := EventToJSON(core.EvRequestStarted{ | |
| 12 | + | Provider: "anthropic", | |
| 13 | + | Model: "claude-test", | |
| 14 | + | Scope: core.RetryScopeProvider, | |
| 15 | + | Attempt: 2, | |
| 16 | + | MaxAttempts: 3, | |
| 17 | + | }) | |
| 18 | + | if got, want := request["type"], "request_started"; got != want { | |
| 19 | + | t.Fatalf("type = %q, want %q", got, want) | |
| 20 | + | } | |
| 21 | + | for key, want := range map[string]any{ | |
| 22 | + | "provider": "anthropic", | |
| 23 | + | "model": "claude-test", | |
| 24 | + | "scope": "provider", | |
| 25 | + | "attempt": 2, | |
| 26 | + | "max_attempts": 3, | |
| 27 | + | } { | |
| 28 | + | if got := request[key]; got != want { | |
| 29 | + | t.Errorf("request[%q] = %#v, want %#v", key, got, want) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | retry := EventToJSON(core.EvRetryScheduled{ | |
| 34 | + | Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 1500 * time.Millisecond, | |
| 35 | + | }) | |
| 36 | + | if got, want := retry["delay_ms"], int64(1500); got != want { | |
| 37 | + | t.Fatalf("delay_ms = %#v, want %#v", got, want) | |
| 38 | + | } | |
| 39 | + | if _, exists := retry["delay"]; exists { | |
| 40 | + | t.Fatal("retry event must not serialize a duration") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | tool := EventToJSON(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | |
| 44 | + | if got, want := tool["type"], "tool_execution_started"; got != want { | |
| 45 | + | t.Fatalf("type = %q, want %q", got, want) | |
| 46 | + | } | |
| 47 | + | if tool["id"] != "call-1" || tool["name"] != "read" { | |
| 48 | + | t.Fatalf("tool event = %#v", tool) | |
| 49 | + | } | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestEventToJSONActivityLifecycle(t *testing.T) { | |||
| 11 | + | request := EventToJSON(core.EvRequestStarted{ | |||
| 12 | + | Provider: "anthropic", | |||
| 13 | + | Model: "claude-test", | |||
| 14 | + | Scope: core.RetryScopeProvider, | |||
| 15 | + | Attempt: 2, | |||
| 16 | + | MaxAttempts: 3, | |||
| 17 | + | }) | |||
| 18 | + | if got, want := request["type"], "request_started"; got != want { | |||
| 19 | + | t.Fatalf("type = %q, want %q", got, want) | |||
| 20 | + | } | |||
| 21 | + | for key, want := range map[string]any{ | |||
| 22 | + | "provider": "anthropic", | |||
| 23 | + | "model": "claude-test", | |||
| 24 | + | "scope": "provider", | |||
| 25 | + | "attempt": 2, | |||
| 26 | + | "max_attempts": 3, | |||
| 27 | + | } { | |||
| 28 | + | if got := request[key]; got != want { | |||
| 29 | + | t.Errorf("request[%q] = %#v, want %#v", key, got, want) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | retry := EventToJSON(core.EvRetryScheduled{ | |||
| 34 | + | Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 1500 * time.Millisecond, | |||
| 35 | + | }) | |||
| 36 | + | if got, want := retry["delay_ms"], int64(1500); got != want { | |||
| 37 | + | t.Fatalf("delay_ms = %#v, want %#v", got, want) | |||
| 38 | + | } | |||
| 39 | + | if _, exists := retry["delay"]; exists { | |||
| 40 | + | t.Fatal("retry event must not serialize a duration") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | tool := EventToJSON(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | |||
| 44 | + | if got, want := tool["type"], "tool_execution_started"; got != want { | |||
| 45 | + | t.Fatalf("type = %q, want %q", got, want) | |||
| 46 | + | } | |||
| 47 | + | if tool["id"] != "call-1" || tool["name"] != "read" { | |||
| 48 | + | t.Fatalf("tool event = %#v", tool) | |||
| 49 | + | } | |||
| 50 | + | } | |||
M packages/agent/modes/llama_dialog.go +1 -1
| @@ -178,7 +178,7 @@ func (d *llamaDialog) load(model string) { | |||
| 178 | 178 | d.mu.Lock() | |
| 179 | 179 | client := d.client | |
| 180 | 180 | d.mu.Unlock() | |
| 181 | - | d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 181 | + | d.beginProgress("Loading local model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 182 | 182 | _, err := client.LoadAndWait(ctx, model, update) | |
| 183 | 183 | return err | |
| 184 | 184 | }) | |
| @@ -178,7 +178,7 @@ func (d *llamaDialog) load(model string) { | |||||
| 178 | d.mu.Lock() | 178 | d.mu.Lock() | ||
| 179 | client := d.client | 179 | client := d.client | ||
| 180 | d.mu.Unlock() | 180 | d.mu.Unlock() | ||
| 181 | - | d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | 181 | + | d.beginProgress("Loading local model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { |
| 182 | _, err := client.LoadAndWait(ctx, model, update) | 182 | _, err := client.LoadAndWait(ctx, model, update) | ||
| 183 | return err | 183 | return err | ||
| 184 | }) | 184 | }) | ||
M packages/agent/modes/rescue_dialog.go +1 -1
| @@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) { | |||
| 233 | 233 | ||
| 234 | 234 | // Don't trigger on context overflow; that path already has its own | |
| 235 | 235 | // auto-compact handling. | |
| 236 | - | if isContextOverflowError(err) { | |
| 236 | + | if provider.IsContextOverflowError(err) { | |
| 237 | 237 | return false, "" | |
| 238 | 238 | } | |
| 239 | 239 | ||
| @@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) { | |||||
| 233 | 233 | ||||
| 234 | // Don't trigger on context overflow; that path already has its own | 234 | // Don't trigger on context overflow; that path already has its own | ||
| 235 | // auto-compact handling. | 235 | // auto-compact handling. | ||
| 236 | - | if isContextOverflowError(err) { | 236 | + | if provider.IsContextOverflowError(err) { |
| 237 | return false, "" | 237 | return false, "" | ||
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
M packages/agent/modes/session_dialog.go +2 -2
| @@ -514,9 +514,9 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |||
| 514 | 514 | } | |
| 515 | 515 | idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames) | |
| 516 | 516 | } | |
| 517 | - | progress := "loading sessions" | |
| 517 | + | progress := "Loading sessions" | |
| 518 | 518 | if d.loadingTotal > 0 { | |
| 519 | - | progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) | |
| 519 | + | progress = fmt.Sprintf("Loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) | |
| 520 | 520 | } | |
| 521 | 521 | return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)") | |
| 522 | 522 | } | |
| @@ -514,9 +514,9 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string { | |||||
| 514 | } | 514 | } | ||
| 515 | idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames) | 515 | idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames) | ||
| 516 | } | 516 | } | ||
| 517 | - | progress := "loading sessions" | 517 | + | progress := "Loading sessions" |
| 518 | if d.loadingTotal > 0 { | 518 | if d.loadingTotal > 0 { | ||
| 519 | - | progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) | 519 | + | progress = fmt.Sprintf("Loading sessions (%d/%d)", d.loadingDone, d.loadingTotal) |
| 520 | } | 520 | } | ||
| 521 | return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)") | 521 | return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)") | ||
| 522 | } | 522 | } | ||
M packages/agent/modes/session_dialog_test.go +2 -2
| @@ -52,7 +52,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||
| 52 | 52 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | |
| 53 | 53 | } | |
| 54 | 54 | loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 55 | - | if !strings.Contains(loadingText, "loading sessions") { | |
| 55 | + | if !strings.Contains(loadingText, "Loading sessions") { | |
| 56 | 56 | t.Fatalf("loading render = %q, want spinner status", loadingText) | |
| 57 | 57 | } | |
| 58 | 58 | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| @@ -71,7 +71,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||
| 71 | 71 | t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) | |
| 72 | 72 | } | |
| 73 | 73 | loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 74 | - | if strings.Contains(loadedText, "loading sessions") { | |
| 74 | + | if strings.Contains(loadedText, "Loading sessions") { | |
| 75 | 75 | t.Fatalf("completed render still shows loading spinner: %q", loadedText) | |
| 76 | 76 | } | |
| 77 | 77 | if !strings.Contains(loadedText, "load this session") { | |
| @@ -52,7 +52,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||||
| 52 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | 52 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | ||
| 53 | } | 53 | } | ||
| 54 | loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") | 54 | loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") | ||
| 55 | - | if !strings.Contains(loadingText, "loading sessions") { | 55 | + | if !strings.Contains(loadingText, "Loading sessions") { |
| 56 | t.Fatalf("loading render = %q, want spinner status", loadingText) | 56 | t.Fatalf("loading render = %q, want spinner status", loadingText) | ||
| 57 | } | 57 | } | ||
| 58 | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | 58 | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | ||
| @@ -71,7 +71,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||||
| 71 | t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) | 71 | t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) | ||
| 72 | } | 72 | } | ||
| 73 | loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") | 73 | loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") | ||
| 74 | - | if strings.Contains(loadedText, "loading sessions") { | 74 | + | if strings.Contains(loadedText, "Loading sessions") { |
| 75 | t.Fatalf("completed render still shows loading spinner: %q", loadedText) | 75 | t.Fatalf("completed render still shows loading spinner: %q", loadedText) | ||
| 76 | } | 76 | } | ||
| 77 | if !strings.Contains(loadedText, "load this session") { | 77 | if !strings.Contains(loadedText, "load this session") { | ||
M packages/agent/modes/spinner.go +4 -55
| @@ -1,26 +1,18 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "math/rand" | |
| 5 | 4 | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/zut/packages/tui" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | - | // spinner drives the busy animation shown in the status bar while a | |
| 11 | - | // turn is streaming. It rotates through a list of playful status | |
| 12 | - | // messages and a small frame animation. | |
| 9 | + | // spinner drives the busy animation shown while an operation is active. The | |
| 10 | + | // operation label is owned by the caller's activity state; the spinner owns | |
| 11 | + | // only the visual frame and elapsed time. | |
| 13 | 12 | type spinner struct { | |
| 14 | 13 | frames []string | |
| 15 | - | messages []string | |
| 16 | 14 | interval time.Duration | |
| 17 | 15 | startedAt time.Time | |
| 18 | - | msgIdx int | |
| 19 | - | ||
| 20 | - | // fixedMsg overrides the rotating funnyWorkingLines message when | |
| 21 | - | // set. Used for auto-compaction so the spinner clearly says what's | |
| 22 | - | // happening instead of cycling jokes. | |
| 23 | - | fixedMsg string | |
| 24 | 16 | } | |
| 25 | 17 | ||
| 26 | 18 | // newSpinner constructs a fresh spinner. | |
| @@ -35,42 +27,16 @@ func (s *spinner) Configure(th tui.Theme) { | |||
| 35 | 27 | if len(s.frames) == 0 { | |
| 36 | 28 | s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} | |
| 37 | 29 | } | |
| 38 | - | s.messages = append([]string(nil), th.SpinnerMessages...) | |
| 39 | - | if len(s.messages) == 0 { | |
| 40 | - | s.messages = []string{"thinking"} | |
| 41 | - | } | |
| 42 | 30 | interval := th.SpinnerIntervalMS | |
| 43 | 31 | if interval <= 0 { | |
| 44 | 32 | interval = 80 | |
| 45 | 33 | } | |
| 46 | 34 | s.interval = time.Duration(interval) * time.Millisecond | |
| 47 | - | if s.msgIdx >= len(s.messages) { | |
| 48 | - | s.msgIdx = 0 | |
| 49 | - | } | |
| 50 | 35 | } | |
| 51 | 36 | ||
| 52 | - | // Start resets the spinner to the beginning of its animation and | |
| 53 | - | // picks a random message that stays fixed for the whole run. A | |
| 54 | - | // rotating rollodex of quips during a single turn felt noisy in | |
| 55 | - | // practice — you'd see five different phrases for one | |
| 56 | - | // long-running response, which implies progress that isn't | |
| 57 | - | // actually happening. One stable phrase per turn reads calmer | |
| 58 | - | // and the variety across turns (next Start picks another index) | |
| 59 | - | // still keeps the set fresh over a session. | |
| 37 | + | // Start resets the animation clock for a new operation. | |
| 60 | 38 | func (s *spinner) Start() { | |
| 61 | 39 | s.startedAt = time.Now() | |
| 62 | - | if len(s.messages) == 0 { | |
| 63 | - | s.messages = []string{"thinking"} | |
| 64 | - | } | |
| 65 | - | s.msgIdx = rand.Intn(len(s.messages)) | |
| 66 | - | s.fixedMsg = "" | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // StartFixed is like Start but pins the status text to msg for the | |
| 70 | - | // duration of this spinner run. Cleared by the next Start() call. | |
| 71 | - | func (s *spinner) StartFixed(msg string) { | |
| 72 | - | s.startedAt = time.Now() | |
| 73 | - | s.fixedMsg = msg | |
| 74 | 40 | } | |
| 75 | 41 | ||
| 76 | 42 | // Frame returns the current spinner glyph for the running animation. | |
| @@ -112,23 +78,6 @@ func (s *spinner) FrameAt(now time.Time) string { | |||
| 112 | 78 | return s.frames[idx] | |
| 113 | 79 | } | |
| 114 | 80 | ||
| 115 | - | // Message returns the spinner's status text. One random phrase | |
| 116 | - | // per Start call, pinned until the next turn. When the spinner | |
| 117 | - | // was started via StartFixed, the pinned message is returned | |
| 118 | - | // unchanged. | |
| 119 | - | func (s *spinner) Message() string { | |
| 120 | - | if s.fixedMsg != "" { | |
| 121 | - | return s.fixedMsg | |
| 122 | - | } | |
| 123 | - | if len(s.messages) == 0 { | |
| 124 | - | return "thinking" | |
| 125 | - | } | |
| 126 | - | if s.msgIdx < 0 || s.msgIdx >= len(s.messages) { | |
| 127 | - | s.msgIdx = 0 | |
| 128 | - | } | |
| 129 | - | return s.messages[s.msgIdx] | |
| 130 | - | } | |
| 131 | - | ||
| 132 | 81 | // Elapsed returns the wall-clock duration the spinner has been running. | |
| 133 | 82 | func (s *spinner) Elapsed() time.Duration { | |
| 134 | 83 | if s.startedAt.IsZero() { | |
| @@ -1,26 +1,18 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "math/rand" | |||
| 5 | "time" | 4 | "time" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/zut/packages/tui" | 6 | "github.com/bnema/zut/packages/tui" | ||
| 8 | ) | 7 | ) | ||
| 9 | 8 | ||||
| 10 | - | // spinner drives the busy animation shown in the status bar while a | 9 | + | // spinner drives the busy animation shown while an operation is active. The |
| 11 | - | // turn is streaming. It rotates through a list of playful status | 10 | + | // operation label is owned by the caller's activity state; the spinner owns |
| 12 | - | // messages and a small frame animation. | 11 | + | // only the visual frame and elapsed time. |
| 13 | type spinner struct { | 12 | type spinner struct { | ||
| 14 | frames []string | 13 | frames []string | ||
| 15 | - | messages []string | |||
| 16 | interval time.Duration | 14 | interval time.Duration | ||
| 17 | startedAt time.Time | 15 | startedAt time.Time | ||
| 18 | - | msgIdx int | |||
| 19 | - | ||||
| 20 | - | // fixedMsg overrides the rotating funnyWorkingLines message when | |||
| 21 | - | // set. Used for auto-compaction so the spinner clearly says what's | |||
| 22 | - | // happening instead of cycling jokes. | |||
| 23 | - | fixedMsg string | |||
| 24 | } | 16 | } | ||
| 25 | 17 | ||||
| 26 | // newSpinner constructs a fresh spinner. | 18 | // newSpinner constructs a fresh spinner. | ||
| @@ -35,42 +27,16 @@ func (s *spinner) Configure(th tui.Theme) { | |||||
| 35 | if len(s.frames) == 0 { | 27 | if len(s.frames) == 0 { | ||
| 36 | s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} | 28 | s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} | ||
| 37 | } | 29 | } | ||
| 38 | - | s.messages = append([]string(nil), th.SpinnerMessages...) | |||
| 39 | - | if len(s.messages) == 0 { | |||
| 40 | - | s.messages = []string{"thinking"} | |||
| 41 | - | } | |||
| 42 | interval := th.SpinnerIntervalMS | 30 | interval := th.SpinnerIntervalMS | ||
| 43 | if interval <= 0 { | 31 | if interval <= 0 { | ||
| 44 | interval = 80 | 32 | interval = 80 | ||
| 45 | } | 33 | } | ||
| 46 | s.interval = time.Duration(interval) * time.Millisecond | 34 | s.interval = time.Duration(interval) * time.Millisecond | ||
| 47 | - | if s.msgIdx >= len(s.messages) { | |||
| 48 | - | s.msgIdx = 0 | |||
| 49 | - | } | |||
| 50 | } | 35 | } | ||
| 51 | 36 | ||||
| 52 | - | // Start resets the spinner to the beginning of its animation and | 37 | + | // Start resets the animation clock for a new operation. |
| 53 | - | // picks a random message that stays fixed for the whole run. A | |||
| 54 | - | // rotating rollodex of quips during a single turn felt noisy in | |||
| 55 | - | // practice — you'd see five different phrases for one | |||
| 56 | - | // long-running response, which implies progress that isn't | |||
| 57 | - | // actually happening. One stable phrase per turn reads calmer | |||
| 58 | - | // and the variety across turns (next Start picks another index) | |||
| 59 | - | // still keeps the set fresh over a session. | |||
| 60 | func (s *spinner) Start() { | 38 | func (s *spinner) Start() { | ||
| 61 | s.startedAt = time.Now() | 39 | s.startedAt = time.Now() | ||
| 62 | - | if len(s.messages) == 0 { | |||
| 63 | - | s.messages = []string{"thinking"} | |||
| 64 | - | } | |||
| 65 | - | s.msgIdx = rand.Intn(len(s.messages)) | |||
| 66 | - | s.fixedMsg = "" | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // StartFixed is like Start but pins the status text to msg for the | |||
| 70 | - | // duration of this spinner run. Cleared by the next Start() call. | |||
| 71 | - | func (s *spinner) StartFixed(msg string) { | |||
| 72 | - | s.startedAt = time.Now() | |||
| 73 | - | s.fixedMsg = msg | |||
| 74 | } | 40 | } | ||
| 75 | 41 | ||||
| 76 | // Frame returns the current spinner glyph for the running animation. | 42 | // Frame returns the current spinner glyph for the running animation. | ||
| @@ -112,23 +78,6 @@ func (s *spinner) FrameAt(now time.Time) string { | |||||
| 112 | return s.frames[idx] | 78 | return s.frames[idx] | ||
| 113 | } | 79 | } | ||
| 114 | 80 | ||||
| 115 | - | // Message returns the spinner's status text. One random phrase | |||
| 116 | - | // per Start call, pinned until the next turn. When the spinner | |||
| 117 | - | // was started via StartFixed, the pinned message is returned | |||
| 118 | - | // unchanged. | |||
| 119 | - | func (s *spinner) Message() string { | |||
| 120 | - | if s.fixedMsg != "" { | |||
| 121 | - | return s.fixedMsg | |||
| 122 | - | } | |||
| 123 | - | if len(s.messages) == 0 { | |||
| 124 | - | return "thinking" | |||
| 125 | - | } | |||
| 126 | - | if s.msgIdx < 0 || s.msgIdx >= len(s.messages) { | |||
| 127 | - | s.msgIdx = 0 | |||
| 128 | - | } | |||
| 129 | - | return s.messages[s.msgIdx] | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | // Elapsed returns the wall-clock duration the spinner has been running. | 81 | // Elapsed returns the wall-clock duration the spinner has been running. | ||
| 133 | func (s *spinner) Elapsed() time.Duration { | 82 | func (s *spinner) Elapsed() time.Duration { | ||
| 134 | if s.startedAt.IsZero() { | 83 | if s.startedAt.IsZero() { | ||
M packages/agent/rpc.go +3 -1
| @@ -442,7 +442,9 @@ func (s *rpcServer) runCompact(id string) { | |||
| 442 | 442 | defer s.setCancel(nil) | |
| 443 | 443 | ||
| 444 | 444 | s.writeResponse(id, "compact", map[string]any{"started": true}) | |
| 445 | - | summary, err := s.agent.Compact(subCtx, 4, nil) | |
| 445 | + | summary, err := s.agent.CompactWithEvents(subCtx, 4, func(ev core.AgentEvent) { | |
| 446 | + | s.writeEvent(modes.EventToJSON(ev)) | |
| 447 | + | }) | |
| 446 | 448 | if err != nil { | |
| 447 | 449 | if !errors.Is(err, context.Canceled) { | |
| 448 | 450 | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | |
| @@ -442,7 +442,9 @@ func (s *rpcServer) runCompact(id string) { | |||||
| 442 | defer s.setCancel(nil) | 442 | defer s.setCancel(nil) | ||
| 443 | 443 | ||||
| 444 | s.writeResponse(id, "compact", map[string]any{"started": true}) | 444 | s.writeResponse(id, "compact", map[string]any{"started": true}) | ||
| 445 | - | summary, err := s.agent.Compact(subCtx, 4, nil) | 445 | + | summary, err := s.agent.CompactWithEvents(subCtx, 4, func(ev core.AgentEvent) { |
| 446 | + | s.writeEvent(modes.EventToJSON(ev)) | |||
| 447 | + | }) | |||
| 446 | if err != nil { | 448 | if err != nil { | ||
| 447 | if !errors.Is(err, context.Canceled) { | 449 | if !errors.Is(err, context.Canceled) { | ||
| 448 | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | 450 | s.writeEvent(map[string]any{"type": "error", "message": err.Error()}) | ||
M packages/agent/sdk/types.go +32 -0
| @@ -18,6 +18,15 @@ type Event struct { | |||
| 18 | 18 | Step int `json:"step,omitempty"` | |
| 19 | 19 | Time string `json:"time,omitempty"` | |
| 20 | 20 | ||
| 21 | + | // request_started / retry_scheduled | |
| 22 | + | Provider string `json:"provider,omitempty"` | |
| 23 | + | Model string `json:"model,omitempty"` | |
| 24 | + | Scope string `json:"scope,omitempty"` | |
| 25 | + | Attempt int `json:"attempt,omitempty"` | |
| 26 | + | MaxAttempts int `json:"max_attempts,omitempty"` | |
| 27 | + | // DelayMS is set for retry_scheduled, including a valid zero delay. | |
| 28 | + | DelayMS *int64 `json:"delay_ms,omitempty"` | |
| 29 | + | ||
| 21 | 30 | // text_delta | |
| 22 | 31 | Delta string `json:"delta,omitempty"` | |
| 23 | 32 | ||
| @@ -131,16 +140,39 @@ func toEvent(ev core.AgentEvent) Event { | |||
| 131 | 140 | switch e := ev.(type) { | |
| 132 | 141 | case core.EvTurnStart: | |
| 133 | 142 | out.Step = e.Step | |
| 143 | + | case core.EvRequestStarted: | |
| 144 | + | out.Provider = e.Provider | |
| 145 | + | out.Model = e.Model | |
| 146 | + | out.Scope = string(e.Scope) | |
| 147 | + | out.Attempt = e.Attempt | |
| 148 | + | out.MaxAttempts = e.MaxAttempts | |
| 149 | + | case core.EvRetryScheduled: | |
| 150 | + | out.Scope = string(e.Scope) | |
| 151 | + | out.Attempt = e.Attempt | |
| 152 | + | out.MaxAttempts = e.MaxAttempts | |
| 153 | + | delayMS := e.Delay.Milliseconds() | |
| 154 | + | out.DelayMS = &delayMS | |
| 134 | 155 | case core.EvUserMessage: | |
| 135 | 156 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |
| 136 | 157 | case core.EvAssistantMessage: | |
| 137 | 158 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | |
| 138 | 159 | case core.EvTextDelta: | |
| 139 | 160 | out.Delta = e.Delta | |
| 161 | + | case core.EvToolUseStart: | |
| 162 | + | out.ID = e.ID | |
| 163 | + | out.Name = e.Name | |
| 164 | + | case core.EvToolUseArgs: | |
| 165 | + | out.ID = e.ID | |
| 166 | + | out.Delta = e.Delta | |
| 167 | + | case core.EvToolUseEnd: | |
| 168 | + | out.ID = e.ID | |
| 140 | 169 | case core.EvToolCall: | |
| 141 | 170 | out.ID = e.ID | |
| 142 | 171 | out.Name = e.Name | |
| 143 | 172 | out.Args = e.Args | |
| 173 | + | case core.EvToolExecutionStarted: | |
| 174 | + | out.ID = e.ID | |
| 175 | + | out.Name = e.Name | |
| 144 | 176 | case core.EvToolProgress: | |
| 145 | 177 | out.ID = e.ID | |
| 146 | 178 | out.Text = e.Text | |
| @@ -18,6 +18,15 @@ type Event struct { | |||||
| 18 | Step int `json:"step,omitempty"` | 18 | Step int `json:"step,omitempty"` | ||
| 19 | Time string `json:"time,omitempty"` | 19 | Time string `json:"time,omitempty"` | ||
| 20 | 20 | ||||
| 21 | + | // request_started / retry_scheduled | |||
| 22 | + | Provider string `json:"provider,omitempty"` | |||
| 23 | + | Model string `json:"model,omitempty"` | |||
| 24 | + | Scope string `json:"scope,omitempty"` | |||
| 25 | + | Attempt int `json:"attempt,omitempty"` | |||
| 26 | + | MaxAttempts int `json:"max_attempts,omitempty"` | |||
| 27 | + | // DelayMS is set for retry_scheduled, including a valid zero delay. | |||
| 28 | + | DelayMS *int64 `json:"delay_ms,omitempty"` | |||
| 29 | + | ||||
| 21 | // text_delta | 30 | // text_delta | ||
| 22 | Delta string `json:"delta,omitempty"` | 31 | Delta string `json:"delta,omitempty"` | ||
| 23 | 32 | ||||
| @@ -131,16 +140,39 @@ func toEvent(ev core.AgentEvent) Event { | |||||
| 131 | switch e := ev.(type) { | 140 | switch e := ev.(type) { | ||
| 132 | case core.EvTurnStart: | 141 | case core.EvTurnStart: | ||
| 133 | out.Step = e.Step | 142 | out.Step = e.Step | ||
| 143 | + | case core.EvRequestStarted: | |||
| 144 | + | out.Provider = e.Provider | |||
| 145 | + | out.Model = e.Model | |||
| 146 | + | out.Scope = string(e.Scope) | |||
| 147 | + | out.Attempt = e.Attempt | |||
| 148 | + | out.MaxAttempts = e.MaxAttempts | |||
| 149 | + | case core.EvRetryScheduled: | |||
| 150 | + | out.Scope = string(e.Scope) | |||
| 151 | + | out.Attempt = e.Attempt | |||
| 152 | + | out.MaxAttempts = e.MaxAttempts | |||
| 153 | + | delayMS := e.Delay.Milliseconds() | |||
| 154 | + | out.DelayMS = &delayMS | |||
| 134 | case core.EvUserMessage: | 155 | case core.EvUserMessage: | ||
| 135 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | 156 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | ||
| 136 | case core.EvAssistantMessage: | 157 | case core.EvAssistantMessage: | ||
| 137 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | 158 | out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)} | ||
| 138 | case core.EvTextDelta: | 159 | case core.EvTextDelta: | ||
| 139 | out.Delta = e.Delta | 160 | out.Delta = e.Delta | ||
| 161 | + | case core.EvToolUseStart: | |||
| 162 | + | out.ID = e.ID | |||
| 163 | + | out.Name = e.Name | |||
| 164 | + | case core.EvToolUseArgs: | |||
| 165 | + | out.ID = e.ID | |||
| 166 | + | out.Delta = e.Delta | |||
| 167 | + | case core.EvToolUseEnd: | |||
| 168 | + | out.ID = e.ID | |||
| 140 | case core.EvToolCall: | 169 | case core.EvToolCall: | ||
| 141 | out.ID = e.ID | 170 | out.ID = e.ID | ||
| 142 | out.Name = e.Name | 171 | out.Name = e.Name | ||
| 143 | out.Args = e.Args | 172 | out.Args = e.Args | ||
| 173 | + | case core.EvToolExecutionStarted: | |||
| 174 | + | out.ID = e.ID | |||
| 175 | + | out.Name = e.Name | |||
| 144 | case core.EvToolProgress: | 176 | case core.EvToolProgress: | ||
| 145 | out.ID = e.ID | 177 | out.ID = e.ID | ||
| 146 | out.Text = e.Text | 178 | out.Text = e.Text | ||
A packages/agent/sdk/types_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package sdk | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | |
| 12 | + | retry := toEvent(core.EvRetryScheduled{ | |
| 13 | + | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | |
| 14 | + | }) | |
| 15 | + | if retry.Type != "retry_scheduled" || retry.Scope != "provider" || retry.Attempt != 2 || retry.MaxAttempts != 3 || retry.DelayMS == nil || *retry.DelayMS != 250 { | |
| 16 | + | t.Fatalf("retry event = %#v", retry) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | zeroDelay := toEvent(core.EvRetryScheduled{Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3}) | |
| 20 | + | encoded, err := json.Marshal(zeroDelay) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatalf("marshal zero-delay retry: %v", err) | |
| 23 | + | } | |
| 24 | + | var wire map[string]any | |
| 25 | + | if err := json.Unmarshal(encoded, &wire); err != nil { | |
| 26 | + | t.Fatalf("decode zero-delay retry: %v", err) | |
| 27 | + | } | |
| 28 | + | if got, ok := wire["delay_ms"]; !ok || got != float64(0) { | |
| 29 | + | t.Fatalf("zero-delay retry JSON delay_ms = %#v, want 0", got) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | toolStart := toEvent(core.EvToolUseStart{ID: "call-1", Name: "read"}) | |
| 33 | + | if toolStart.Type != "tool_use_start" || toolStart.ID != "call-1" || toolStart.Name != "read" { | |
| 34 | + | t.Fatalf("tool start = %#v", toolStart) | |
| 35 | + | } | |
| 36 | + | toolArgs := toEvent(core.EvToolUseArgs{ID: "call-1", Delta: `{"path":"a"`}) | |
| 37 | + | if toolArgs.Type != "tool_use_args" || toolArgs.ID != "call-1" || toolArgs.Delta == "" { | |
| 38 | + | t.Fatalf("tool args = %#v", toolArgs) | |
| 39 | + | } | |
| 40 | + | toolEnd := toEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | |
| 41 | + | if toolEnd.Type != "tool_execution_started" || toolEnd.ID != "call-1" || toolEnd.Name != "read" { | |
| 42 | + | t.Fatalf("tool execution = %#v", toolEnd) | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package sdk | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | |||
| 12 | + | retry := toEvent(core.EvRetryScheduled{ | |||
| 13 | + | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | |||
| 14 | + | }) | |||
| 15 | + | if retry.Type != "retry_scheduled" || retry.Scope != "provider" || retry.Attempt != 2 || retry.MaxAttempts != 3 || retry.DelayMS == nil || *retry.DelayMS != 250 { | |||
| 16 | + | t.Fatalf("retry event = %#v", retry) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | zeroDelay := toEvent(core.EvRetryScheduled{Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3}) | |||
| 20 | + | encoded, err := json.Marshal(zeroDelay) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatalf("marshal zero-delay retry: %v", err) | |||
| 23 | + | } | |||
| 24 | + | var wire map[string]any | |||
| 25 | + | if err := json.Unmarshal(encoded, &wire); err != nil { | |||
| 26 | + | t.Fatalf("decode zero-delay retry: %v", err) | |||
| 27 | + | } | |||
| 28 | + | if got, ok := wire["delay_ms"]; !ok || got != float64(0) { | |||
| 29 | + | t.Fatalf("zero-delay retry JSON delay_ms = %#v, want 0", got) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | toolStart := toEvent(core.EvToolUseStart{ID: "call-1", Name: "read"}) | |||
| 33 | + | if toolStart.Type != "tool_use_start" || toolStart.ID != "call-1" || toolStart.Name != "read" { | |||
| 34 | + | t.Fatalf("tool start = %#v", toolStart) | |||
| 35 | + | } | |||
| 36 | + | toolArgs := toEvent(core.EvToolUseArgs{ID: "call-1", Delta: `{"path":"a"`}) | |||
| 37 | + | if toolArgs.Type != "tool_use_args" || toolArgs.ID != "call-1" || toolArgs.Delta == "" { | |||
| 38 | + | t.Fatalf("tool args = %#v", toolArgs) | |||
| 39 | + | } | |||
| 40 | + | toolEnd := toEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | |||
| 41 | + | if toolEnd.Type != "tool_execution_started" || toolEnd.ID != "call-1" || toolEnd.Name != "read" { | |||
| 42 | + | t.Fatalf("tool execution = %#v", toolEnd) | |||
| 43 | + | } | |||
| 44 | + | } | |||
M packages/agent/skills/builtin/write-zut-themes/SKILL.md +5 -7
| @@ -88,7 +88,6 @@ Top-level spinner overrides are valid and apply to both modes. | |||
| 88 | 88 | "name": "custom-spinner", | |
| 89 | 89 | "description": "Only changes the busy spinner.", | |
| 90 | 90 | "spinner_frames": ["◢", "◣", "◤", "◥"], | |
| 91 | - | "spinner_messages": ["working"], | |
| 92 | 91 | "spinner_interval_ms": 120 | |
| 93 | 92 | } | |
| 94 | 93 | ``` | |
| @@ -103,11 +102,10 @@ settings are used on dark defaults. | |||
| 103 | 102 | ```json | |
| 104 | 103 | { | |
| 105 | 104 | "name": "custom-spinner", | |
| 106 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | |
| 105 | + | "description": "An alternative busy-spinner animation for zut.", | |
| 107 | 106 | "colors": { | |
| 108 | 107 | "dark": { | |
| 109 | 108 | "spinner_frames": ["◢", "◣", "◤", "◥"], | |
| 110 | - | "spinner_messages": ["working"], | |
| 111 | 109 | "spinner_interval_ms": 120 | |
| 112 | 110 | } | |
| 113 | 111 | } | |
| @@ -142,7 +140,6 @@ All fields below are optional. | |||
| 142 | 140 | "selection_bg": 24, | |
| 143 | 141 | "selection_fg": 231, | |
| 144 | 142 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | |
| 145 | - | "spinner_messages": ["thinking", "working"], | |
| 146 | 143 | "spinner_interval_ms": 80, | |
| 147 | 144 | "syntax_base_style": "monokai", | |
| 148 | 145 | "syntax": { | |
| @@ -183,7 +180,7 @@ under `colors`, when they should apply to both modes: | |||
| 183 | 180 | "name": "tiny", | |
| 184 | 181 | "accent": 204, | |
| 185 | 182 | "colors": { | |
| 186 | - | "spinner_messages": ["shipping"] | |
| 183 | + | "spinner_frames": ["◐", "◓", "◑", "◒"] | |
| 187 | 184 | } | |
| 188 | 185 | } | |
| 189 | 186 | ``` | |
| @@ -230,16 +227,17 @@ Spinner settings can appear at top level, under `colors`, or under | |||
| 230 | 227 | ||
| 231 | 228 | - `spinner_frames` — list of frame strings. Use single-cell glyphs | |
| 232 | 229 | when possible so status-bar alignment stays clean. | |
| 233 | - | - `spinner_messages` — list of messages; zut chooses one per turn. | |
| 234 | 230 | - `spinner_interval_ms` — frame interval in milliseconds; must be | |
| 235 | 231 | positive. Missing/invalid falls back to 80ms. | |
| 236 | 232 | ||
| 233 | + | The working-status label belongs to zut and always describes the current | |
| 234 | + | operation. The legacy `spinner_messages` field is ignored. | |
| 235 | + | ||
| 237 | 236 | Example: | |
| 238 | 237 | ||
| 239 | 238 | ```json | |
| 240 | 239 | { | |
| 241 | 240 | "spinner_frames": ["◐", "◓", "◑", "◒"], | |
| 242 | - | "spinner_messages": ["shipping pixels", "warming edge cache"], | |
| 243 | 241 | "spinner_interval_ms": 120 | |
| 244 | 242 | } | |
| 245 | 243 | ``` | |
| @@ -88,7 +88,6 @@ Top-level spinner overrides are valid and apply to both modes. | |||||
| 88 | "name": "custom-spinner", | 88 | "name": "custom-spinner", | ||
| 89 | "description": "Only changes the busy spinner.", | 89 | "description": "Only changes the busy spinner.", | ||
| 90 | "spinner_frames": ["◢", "◣", "◤", "◥"], | 90 | "spinner_frames": ["◢", "◣", "◤", "◥"], | ||
| 91 | - | "spinner_messages": ["working"], | |||
| 92 | "spinner_interval_ms": 120 | 91 | "spinner_interval_ms": 120 | ||
| 93 | } | 92 | } | ||
| 94 | ``` | 93 | ``` | ||
| @@ -103,11 +102,10 @@ settings are used on dark defaults. | |||||
| 103 | ```json | 102 | ```json | ||
| 104 | { | 103 | { | ||
| 105 | "name": "custom-spinner", | 104 | "name": "custom-spinner", | ||
| 106 | - | "description": "An alternative spinner for zut that only displays a single spinner text.", | 105 | + | "description": "An alternative busy-spinner animation for zut.", |
| 107 | "colors": { | 106 | "colors": { | ||
| 108 | "dark": { | 107 | "dark": { | ||
| 109 | "spinner_frames": ["◢", "◣", "◤", "◥"], | 108 | "spinner_frames": ["◢", "◣", "◤", "◥"], | ||
| 110 | - | "spinner_messages": ["working"], | |||
| 111 | "spinner_interval_ms": 120 | 109 | "spinner_interval_ms": 120 | ||
| 112 | } | 110 | } | ||
| 113 | } | 111 | } | ||
| @@ -142,7 +140,6 @@ All fields below are optional. | |||||
| 142 | "selection_bg": 24, | 140 | "selection_bg": 24, | ||
| 143 | "selection_fg": 231, | 141 | "selection_fg": 231, | ||
| 144 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | 142 | "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"], | ||
| 145 | - | "spinner_messages": ["thinking", "working"], | |||
| 146 | "spinner_interval_ms": 80, | 143 | "spinner_interval_ms": 80, | ||
| 147 | "syntax_base_style": "monokai", | 144 | "syntax_base_style": "monokai", | ||
| 148 | "syntax": { | 145 | "syntax": { | ||
| @@ -183,7 +180,7 @@ under `colors`, when they should apply to both modes: | |||||
| 183 | "name": "tiny", | 180 | "name": "tiny", | ||
| 184 | "accent": 204, | 181 | "accent": 204, | ||
| 185 | "colors": { | 182 | "colors": { | ||
| 186 | - | "spinner_messages": ["shipping"] | 183 | + | "spinner_frames": ["◐", "◓", "◑", "◒"] |
| 187 | } | 184 | } | ||
| 188 | } | 185 | } | ||
| 189 | ``` | 186 | ``` | ||
| @@ -230,16 +227,17 @@ Spinner settings can appear at top level, under `colors`, or under | |||||
| 230 | 227 | ||||
| 231 | - `spinner_frames` — list of frame strings. Use single-cell glyphs | 228 | - `spinner_frames` — list of frame strings. Use single-cell glyphs | ||
| 232 | when possible so status-bar alignment stays clean. | 229 | when possible so status-bar alignment stays clean. | ||
| 233 | - | - `spinner_messages` — list of messages; zut chooses one per turn. | |||
| 234 | - `spinner_interval_ms` — frame interval in milliseconds; must be | 230 | - `spinner_interval_ms` — frame interval in milliseconds; must be | ||
| 235 | positive. Missing/invalid falls back to 80ms. | 231 | positive. Missing/invalid falls back to 80ms. | ||
| 236 | 232 | ||||
| 233 | + | The working-status label belongs to zut and always describes the current | |||
| 234 | + | operation. The legacy `spinner_messages` field is ignored. | |||
| 235 | + | ||||
| 237 | Example: | 236 | Example: | ||
| 238 | 237 | ||||
| 239 | ```json | 238 | ```json | ||
| 240 | { | 239 | { | ||
| 241 | "spinner_frames": ["◐", "◓", "◑", "◒"], | 240 | "spinner_frames": ["◐", "◓", "◑", "◒"], | ||
| 242 | - | "spinner_messages": ["shipping pixels", "warming edge cache"], | |||
| 243 | "spinner_interval_ms": 120 | 241 | "spinner_interval_ms": 120 | ||
| 244 | } | 242 | } | ||
| 245 | ``` | 243 | ``` | ||
M packages/agent/subagent_worker.go +67 -4
| @@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||
| 201 | 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | |
| 202 | 202 | ||
| 203 | 203 | sink := func(ev core.AgentEvent) { | |
| 204 | - | em.emit(ev.Type(), modes.EventToJSON(ev)) | |
| 204 | + | em.emit(ev.Type(), subagentEventData(ev)) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | - | start := len(ag.Messages()) | |
| 208 | - | err := ag.Prompt(c, prompt, nil, sink) | |
| 207 | + | var persistCompaction func([]provider.Message) error | |
| 208 | + | if sess != nil { | |
| 209 | + | persistCompaction = sess.AppendCompaction | |
| 210 | + | } | |
| 211 | + | start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction) | |
| 209 | 212 | WriteNewTranscript(ag, sess, start) | |
| 210 | 213 | ||
| 211 | 214 | output := finalAssistantText(ag.Messages()[start:]) | |
| @@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||
| 231 | 234 | em.emit("turn_end", map[string]any{ | |
| 232 | 235 | "step": step, | |
| 233 | 236 | "turn_id": turnID, | |
| 234 | - | "error": errString(err), | |
| 237 | + | "error": turnErrorMessage(err), | |
| 235 | 238 | }) | |
| 236 | 239 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 237 | 240 | ||
| @@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||
| 402 | 405 | } | |
| 403 | 406 | } | |
| 404 | 407 | ||
| 408 | + | func subagentEventData(ev core.AgentEvent) map[string]any { | |
| 409 | + | data := modes.EventToJSON(ev) | |
| 410 | + | if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 411 | + | data["error"] = subagentContextLimitMessage | |
| 412 | + | } | |
| 413 | + | return data | |
| 414 | + | } | |
| 415 | + | ||
| 405 | 416 | func canonicalWorkerEvent(typ string) string { | |
| 406 | 417 | switch typ { | |
| 407 | 418 | case "agent_ready": | |
| @@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string { | |||
| 423 | 434 | } | |
| 424 | 435 | } | |
| 425 | 436 | ||
| 437 | + | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | |
| 438 | + | ||
| 439 | + | // promptWithContextRecovery appends prompt and runs it once. When the provider | |
| 440 | + | // rejects that request for exceeding its context limit, it compacts the current | |
| 441 | + | // transcript and continues the already-appended prompt exactly once. | |
| 442 | + | // | |
| 443 | + | // The returned index marks the first message that can contribute to the turn | |
| 444 | + | // result or be appended after a compaction checkpoint. Compact replaces the | |
| 445 | + | // transcript, so callers must not reuse the index captured before Prompt. | |
| 446 | + | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | |
| 447 | + | outputStart = len(ag.Messages()) | |
| 448 | + | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |
| 449 | + | return outputStart, err | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | messages := ag.Messages() | |
| 453 | + | if len(messages) <= 1 { | |
| 454 | + | // The only message is the failed prompt. Summarizing it would replace | |
| 455 | + | // the user's task with an LLM-generated paraphrase, so report the | |
| 456 | + | // provider's limit and ask the caller to narrow the task instead. | |
| 457 | + | return outputStart, err | |
| 458 | + | } | |
| 459 | + | keepTail := min(4, len(messages)-1) | |
| 460 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |
| 461 | + | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | compacted := ag.Messages() | |
| 465 | + | if persistCompaction != nil { | |
| 466 | + | if persistErr := persistCompaction(compacted); persistErr != nil { | |
| 467 | + | // Keep the original history plus the user's pending task so the | |
| 468 | + | // outer transcript flush can persist that task without committing | |
| 469 | + | // an in-memory compaction that the session did not receive. | |
| 470 | + | ag.SetMessages(messages) | |
| 471 | + | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | outputStart = len(compacted) | |
| 476 | + | return outputStart, ag.Continue(ctx, sink) | |
| 477 | + | } | |
| 478 | + | ||
| 426 | 479 | func finalAssistantText(messages []provider.Message) string { | |
| 427 | 480 | var out strings.Builder | |
| 428 | 481 | for _, message := range messages { | |
| @@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any { | |||
| 498 | 551 | if err == nil { | |
| 499 | 552 | return nil | |
| 500 | 553 | } | |
| 554 | + | if provider.IsContextOverflowError(err) { | |
| 555 | + | return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage} | |
| 556 | + | } | |
| 501 | 557 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | |
| 502 | 558 | } | |
| 503 | 559 | ||
| 560 | + | func turnErrorMessage(err error) string { | |
| 561 | + | if provider.IsContextOverflowError(err) { | |
| 562 | + | return subagentContextLimitMessage | |
| 563 | + | } | |
| 564 | + | return errString(err) | |
| 565 | + | } | |
| 566 | + | ||
| 504 | 567 | func errString(err error) string { | |
| 505 | 568 | if err == nil { | |
| 506 | 569 | return "" | |
| @@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||||
| 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | ||
| 202 | 202 | ||||
| 203 | sink := func(ev core.AgentEvent) { | 203 | sink := func(ev core.AgentEvent) { | ||
| 204 | - | em.emit(ev.Type(), modes.EventToJSON(ev)) | 204 | + | em.emit(ev.Type(), subagentEventData(ev)) |
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | - | start := len(ag.Messages()) | 207 | + | var persistCompaction func([]provider.Message) error |
| 208 | - | err := ag.Prompt(c, prompt, nil, sink) | 208 | + | if sess != nil { |
| 209 | + | persistCompaction = sess.AppendCompaction | |||
| 210 | + | } | |||
| 211 | + | start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction) | |||
| 209 | WriteNewTranscript(ag, sess, start) | 212 | WriteNewTranscript(ag, sess, start) | ||
| 210 | 213 | ||||
| 211 | output := finalAssistantText(ag.Messages()[start:]) | 214 | output := finalAssistantText(ag.Messages()[start:]) | ||
| @@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||||
| 231 | em.emit("turn_end", map[string]any{ | 234 | em.emit("turn_end", map[string]any{ | ||
| 232 | "step": step, | 235 | "step": step, | ||
| 233 | "turn_id": turnID, | 236 | "turn_id": turnID, | ||
| 234 | - | "error": errString(err), | 237 | + | "error": turnErrorMessage(err), |
| 235 | }) | 238 | }) | ||
| 236 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | 239 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | ||
| 237 | 240 | ||||
| @@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||||
| 402 | } | 405 | } | ||
| 403 | } | 406 | } | ||
| 404 | 407 | ||||
| 408 | + | func subagentEventData(ev core.AgentEvent) map[string]any { | |||
| 409 | + | data := modes.EventToJSON(ev) | |||
| 410 | + | if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 411 | + | data["error"] = subagentContextLimitMessage | |||
| 412 | + | } | |||
| 413 | + | return data | |||
| 414 | + | } | |||
| 415 | + | ||||
| 405 | func canonicalWorkerEvent(typ string) string { | 416 | func canonicalWorkerEvent(typ string) string { | ||
| 406 | switch typ { | 417 | switch typ { | ||
| 407 | case "agent_ready": | 418 | case "agent_ready": | ||
| @@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string { | |||||
| 423 | } | 434 | } | ||
| 424 | } | 435 | } | ||
| 425 | 436 | ||||
| 437 | + | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | |||
| 438 | + | ||||
| 439 | + | // promptWithContextRecovery appends prompt and runs it once. When the provider | |||
| 440 | + | // rejects that request for exceeding its context limit, it compacts the current | |||
| 441 | + | // transcript and continues the already-appended prompt exactly once. | |||
| 442 | + | // | |||
| 443 | + | // The returned index marks the first message that can contribute to the turn | |||
| 444 | + | // result or be appended after a compaction checkpoint. Compact replaces the | |||
| 445 | + | // transcript, so callers must not reuse the index captured before Prompt. | |||
| 446 | + | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | |||
| 447 | + | outputStart = len(ag.Messages()) | |||
| 448 | + | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |||
| 449 | + | return outputStart, err | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | messages := ag.Messages() | |||
| 453 | + | if len(messages) <= 1 { | |||
| 454 | + | // The only message is the failed prompt. Summarizing it would replace | |||
| 455 | + | // the user's task with an LLM-generated paraphrase, so report the | |||
| 456 | + | // provider's limit and ask the caller to narrow the task instead. | |||
| 457 | + | return outputStart, err | |||
| 458 | + | } | |||
| 459 | + | keepTail := min(4, len(messages)-1) | |||
| 460 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |||
| 461 | + | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | compacted := ag.Messages() | |||
| 465 | + | if persistCompaction != nil { | |||
| 466 | + | if persistErr := persistCompaction(compacted); persistErr != nil { | |||
| 467 | + | // Keep the original history plus the user's pending task so the | |||
| 468 | + | // outer transcript flush can persist that task without committing | |||
| 469 | + | // an in-memory compaction that the session did not receive. | |||
| 470 | + | ag.SetMessages(messages) | |||
| 471 | + | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | outputStart = len(compacted) | |||
| 476 | + | return outputStart, ag.Continue(ctx, sink) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 426 | func finalAssistantText(messages []provider.Message) string { | 479 | func finalAssistantText(messages []provider.Message) string { | ||
| 427 | var out strings.Builder | 480 | var out strings.Builder | ||
| 428 | for _, message := range messages { | 481 | for _, message := range messages { | ||
| @@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any { | |||||
| 498 | if err == nil { | 551 | if err == nil { | ||
| 499 | return nil | 552 | return nil | ||
| 500 | } | 553 | } | ||
| 554 | + | if provider.IsContextOverflowError(err) { | |||
| 555 | + | return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage} | |||
| 556 | + | } | |||
| 501 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | 557 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | ||
| 502 | } | 558 | } | ||
| 503 | 559 | ||||
| 560 | + | func turnErrorMessage(err error) string { | |||
| 561 | + | if provider.IsContextOverflowError(err) { | |||
| 562 | + | return subagentContextLimitMessage | |||
| 563 | + | } | |||
| 564 | + | return errString(err) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 504 | func errString(err error) string { | 567 | func errString(err error) string { | ||
| 505 | if err == nil { | 568 | if err == nil { | ||
| 506 | return "" | 569 | return "" | ||
M packages/agent/subagent_worker_test.go +340 -0
| @@ -2,14 +2,19 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 6 | 8 | "io" | |
| 7 | 9 | "os" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "strings" | |
| 12 | + | "sync" | |
| 10 | 13 | "testing" | |
| 11 | 14 | ||
| 12 | 15 | "github.com/bnema/zut/packages/agent/subagents" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | 18 | ) | |
| 14 | 19 | ||
| 15 | 20 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | |
| @@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |||
| 30 | 35 | } | |
| 31 | 36 | } | |
| 32 | 37 | ||
| 38 | + | type subagentContextRecoveryClient struct { | |
| 39 | + | mu sync.Mutex | |
| 40 | + | calls int | |
| 41 | + | requests []provider.Request | |
| 42 | + | retried chan provider.Request | |
| 43 | + | compactionErr error | |
| 44 | + | retryOverflow bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" } | |
| 48 | + | ||
| 49 | + | func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 50 | + | c.mu.Lock() | |
| 51 | + | c.calls++ | |
| 52 | + | call := c.calls | |
| 53 | + | c.requests = append(c.requests, req) | |
| 54 | + | c.mu.Unlock() | |
| 55 | + | ||
| 56 | + | events := make(chan provider.Event, 2) | |
| 57 | + | go func() { | |
| 58 | + | defer close(events) | |
| 59 | + | switch call { | |
| 60 | + | case 1: | |
| 61 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |
| 62 | + | case 2: | |
| 63 | + | if c.compactionErr != nil { | |
| 64 | + | events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr} | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | events <- provider.EventTextDelta{Delta: "summary"} | |
| 68 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |
| 69 | + | default: | |
| 70 | + | c.retried <- req | |
| 71 | + | if c.retryOverflow { | |
| 72 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 76 | + | Role: provider.RoleAssistant, | |
| 77 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |
| 78 | + | }} | |
| 79 | + | } | |
| 80 | + | }() | |
| 81 | + | return events, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) { | |
| 85 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 86 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 87 | + | agent.SetMessages([]provider.Message{ | |
| 88 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 89 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 90 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 91 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 92 | + | }) | |
| 93 | + | ||
| 94 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |
| 97 | + | } | |
| 98 | + | if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" { | |
| 99 | + | t.Fatalf("final assistant text = %q, want recovered", got) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | request := <-client.retried | |
| 103 | + | if got := workerRequestUserTextCount(request, "finish the task"); got != 1 { | |
| 104 | + | t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages) | |
| 105 | + | } | |
| 106 | + | client.mu.Lock() | |
| 107 | + | calls := client.calls | |
| 108 | + | requests := append([]provider.Request(nil), client.requests...) | |
| 109 | + | client.mu.Unlock() | |
| 110 | + | if calls != 3 || len(requests) != 3 { | |
| 111 | + | t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests)) | |
| 112 | + | } | |
| 113 | + | if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") { | |
| 114 | + | t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) { | |
| 119 | + | client := &subagentContextRecoveryClient{ | |
| 120 | + | retried: make(chan provider.Request, 1), | |
| 121 | + | retryOverflow: true, | |
| 122 | + | } | |
| 123 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 124 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 125 | + | ||
| 126 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 127 | + | if !provider.IsContextOverflowError(err) { | |
| 128 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 129 | + | } | |
| 130 | + | <-client.retried | |
| 131 | + | client.mu.Lock() | |
| 132 | + | calls := client.calls | |
| 133 | + | client.mu.Unlock() | |
| 134 | + | if calls != 3 { | |
| 135 | + | t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) { | |
| 140 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 141 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 142 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | + | defer cancel() | |
| 145 | + | ||
| 146 | + | _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) { | |
| 147 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 148 | + | cancel() | |
| 149 | + | } | |
| 150 | + | }, nil) | |
| 151 | + | if !provider.IsContextOverflowError(err) { | |
| 152 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 153 | + | } | |
| 154 | + | if err := ctx.Err(); !errors.Is(err, context.Canceled) { | |
| 155 | + | t.Fatalf("context error = %v, want %v", err, context.Canceled) | |
| 156 | + | } | |
| 157 | + | client.mu.Lock() | |
| 158 | + | calls := client.calls | |
| 159 | + | client.mu.Unlock() | |
| 160 | + | if calls != 1 { | |
| 161 | + | t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) { | |
| 166 | + | client := &subagentContextRecoveryClient{ | |
| 167 | + | retried: make(chan provider.Request, 1), | |
| 168 | + | compactionErr: errors.New("compaction provider unavailable"), | |
| 169 | + | } | |
| 170 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 171 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 172 | + | ||
| 173 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 174 | + | if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") { | |
| 175 | + | t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err) | |
| 176 | + | } | |
| 177 | + | payload := resultErrorPayload(err) | |
| 178 | + | if payload["code"] != "turn_failed" { | |
| 179 | + | t.Fatalf("result error payload = %#v, want turn_failed", payload) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) { | |
| 184 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 185 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 186 | + | ||
| 187 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 188 | + | if !provider.IsContextOverflowError(err) { | |
| 189 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 190 | + | } | |
| 191 | + | client.mu.Lock() | |
| 192 | + | calls := client.calls | |
| 193 | + | requests := append([]provider.Request(nil), client.requests...) | |
| 194 | + | client.mu.Unlock() | |
| 195 | + | if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 { | |
| 196 | + | t.Fatalf("initial request does not retain the prompt: %#v", requests) | |
| 197 | + | } | |
| 198 | + | if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |
| 199 | + | t.Fatalf("single prompt was not retained: %#v", agent.Messages()) | |
| 200 | + | } | |
| 201 | + | if calls != 1 { | |
| 202 | + | t.Fatalf("provider calls = %d, want 1 without compaction", calls) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) { | |
| 207 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 208 | + | history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}} | |
| 209 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 210 | + | if err != nil { | |
| 211 | + | t.Fatalf("create session: %v", err) | |
| 212 | + | } | |
| 213 | + | if err := session.AppendMessage(history[0]); err != nil { | |
| 214 | + | t.Fatalf("append history: %v", err) | |
| 215 | + | } | |
| 216 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 217 | + | agent.SetMessages(history) | |
| 218 | + | ||
| 219 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error { | |
| 220 | + | return errors.New("persist failed") | |
| 221 | + | }) | |
| 222 | + | if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") { | |
| 223 | + | t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err) | |
| 224 | + | } | |
| 225 | + | if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |
| 226 | + | t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages()) | |
| 227 | + | } | |
| 228 | + | WriteNewTranscript(agent, session, outputStart) | |
| 229 | + | if err := session.Close(); err != nil { | |
| 230 | + | t.Fatalf("close session: %v", err) | |
| 231 | + | } | |
| 232 | + | reopened, messages, err := core.OpenSession(session.Path) | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatalf("reopen session: %v", err) | |
| 235 | + | } | |
| 236 | + | t.Cleanup(func() { | |
| 237 | + | if err := reopened.Close(); err != nil { | |
| 238 | + | t.Errorf("close reopened session: %v", err) | |
| 239 | + | } | |
| 240 | + | }) | |
| 241 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |
| 242 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |
| 243 | + | } | |
| 244 | + | client.mu.Lock() | |
| 245 | + | calls := client.calls | |
| 246 | + | client.mu.Unlock() | |
| 247 | + | if calls != 2 { | |
| 248 | + | t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) { | |
| 253 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 254 | + | history := []provider.Message{ | |
| 255 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 256 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 257 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 258 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 259 | + | } | |
| 260 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 261 | + | if err != nil { | |
| 262 | + | t.Fatalf("create session: %v", err) | |
| 263 | + | } | |
| 264 | + | for _, message := range history { | |
| 265 | + | if err := session.AppendMessage(message); err != nil { | |
| 266 | + | t.Fatalf("append history: %v", err) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 271 | + | agent.SetMessages(history) | |
| 272 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction) | |
| 273 | + | if err != nil { | |
| 274 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |
| 275 | + | } | |
| 276 | + | WriteNewTranscript(agent, session, outputStart) | |
| 277 | + | if err := session.Close(); err != nil { | |
| 278 | + | t.Fatalf("close session: %v", err) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | records, err := os.ReadFile(session.Path) | |
| 282 | + | if err != nil { | |
| 283 | + | t.Fatalf("read session: %v", err) | |
| 284 | + | } | |
| 285 | + | compactions := 0 | |
| 286 | + | for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) { | |
| 287 | + | var entry struct { | |
| 288 | + | Type string `json:"type"` | |
| 289 | + | } | |
| 290 | + | if err := json.Unmarshal(record, &entry); err != nil { | |
| 291 | + | t.Fatalf("decode session record: %v", err) | |
| 292 | + | } | |
| 293 | + | if entry.Type == "compaction" { | |
| 294 | + | compactions++ | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | if compactions != 1 { | |
| 298 | + | t.Fatalf("compaction records = %d, want 1", compactions) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | reopened, messages, err := core.OpenSession(session.Path) | |
| 302 | + | if err != nil { | |
| 303 | + | t.Fatalf("reopen session: %v", err) | |
| 304 | + | } | |
| 305 | + | t.Cleanup(func() { | |
| 306 | + | if err := reopened.Close(); err != nil { | |
| 307 | + | t.Errorf("close reopened session: %v", err) | |
| 308 | + | } | |
| 309 | + | }) | |
| 310 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |
| 311 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |
| 312 | + | } | |
| 313 | + | if got := finalAssistantText(messages); got != "two\nfour\nrecovered" { | |
| 314 | + | t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func workerRequestMessageTextCount(messages []provider.Message, want string) int { | |
| 319 | + | count := 0 | |
| 320 | + | for _, message := range messages { | |
| 321 | + | for _, block := range message.Content { | |
| 322 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |
| 323 | + | count++ | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return count | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func workerRequestContainsText(req provider.Request, want string) bool { | |
| 331 | + | for _, message := range req.Messages { | |
| 332 | + | for _, block := range message.Content { | |
| 333 | + | if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) { | |
| 334 | + | return true | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | return false | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func workerRequestUserTextCount(req provider.Request, want string) int { | |
| 342 | + | count := 0 | |
| 343 | + | for _, message := range req.Messages { | |
| 344 | + | if message.Role != provider.RoleUser { | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | for _, block := range message.Content { | |
| 348 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |
| 349 | + | count++ | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return count | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) { | |
| 357 | + | payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text")) | |
| 358 | + | if payload["code"] != "context_limit" { | |
| 359 | + | t.Fatalf("error code = %q, want context_limit", payload["code"]) | |
| 360 | + | } | |
| 361 | + | if payload["message"] != subagentContextLimitMessage { | |
| 362 | + | t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |
| 367 | + | payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")}) | |
| 368 | + | if payload["error"] != subagentContextLimitMessage { | |
| 369 | + | t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 33 | 373 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | |
| 34 | 374 | // "everything is doubled after reopening a subagent agent" bug. | |
| 35 | 375 | // | |
| @@ -2,14 +2,19 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 6 | "io" | 8 | "io" | ||
| 7 | "os" | 9 | "os" | ||
| 8 | "path/filepath" | 10 | "path/filepath" | ||
| 9 | "strings" | 11 | "strings" | ||
| 12 | + | "sync" | |||
| 10 | "testing" | 13 | "testing" | ||
| 11 | 14 | ||||
| 12 | "github.com/bnema/zut/packages/agent/subagents" | 15 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 16 | + | "github.com/bnema/zut/packages/core" | |||
| 17 | + | "github.com/bnema/zut/packages/provider" | |||
| 13 | ) | 18 | ) | ||
| 14 | 19 | ||||
| 15 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | 20 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | ||
| @@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |||||
| 30 | } | 35 | } | ||
| 31 | } | 36 | } | ||
| 32 | 37 | ||||
| 38 | + | type subagentContextRecoveryClient struct { | |||
| 39 | + | mu sync.Mutex | |||
| 40 | + | calls int | |||
| 41 | + | requests []provider.Request | |||
| 42 | + | retried chan provider.Request | |||
| 43 | + | compactionErr error | |||
| 44 | + | retryOverflow bool | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" } | |||
| 48 | + | ||||
| 49 | + | func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 50 | + | c.mu.Lock() | |||
| 51 | + | c.calls++ | |||
| 52 | + | call := c.calls | |||
| 53 | + | c.requests = append(c.requests, req) | |||
| 54 | + | c.mu.Unlock() | |||
| 55 | + | ||||
| 56 | + | events := make(chan provider.Event, 2) | |||
| 57 | + | go func() { | |||
| 58 | + | defer close(events) | |||
| 59 | + | switch call { | |||
| 60 | + | case 1: | |||
| 61 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |||
| 62 | + | case 2: | |||
| 63 | + | if c.compactionErr != nil { | |||
| 64 | + | events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr} | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | events <- provider.EventTextDelta{Delta: "summary"} | |||
| 68 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |||
| 69 | + | default: | |||
| 70 | + | c.retried <- req | |||
| 71 | + | if c.retryOverflow { | |||
| 72 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 76 | + | Role: provider.RoleAssistant, | |||
| 77 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |||
| 78 | + | }} | |||
| 79 | + | } | |||
| 80 | + | }() | |||
| 81 | + | return events, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) { | |||
| 85 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 86 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 87 | + | agent.SetMessages([]provider.Message{ | |||
| 88 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 89 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 90 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 91 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 92 | + | }) | |||
| 93 | + | ||||
| 94 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |||
| 97 | + | } | |||
| 98 | + | if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" { | |||
| 99 | + | t.Fatalf("final assistant text = %q, want recovered", got) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | request := <-client.retried | |||
| 103 | + | if got := workerRequestUserTextCount(request, "finish the task"); got != 1 { | |||
| 104 | + | t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages) | |||
| 105 | + | } | |||
| 106 | + | client.mu.Lock() | |||
| 107 | + | calls := client.calls | |||
| 108 | + | requests := append([]provider.Request(nil), client.requests...) | |||
| 109 | + | client.mu.Unlock() | |||
| 110 | + | if calls != 3 || len(requests) != 3 { | |||
| 111 | + | t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests)) | |||
| 112 | + | } | |||
| 113 | + | if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") { | |||
| 114 | + | t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) { | |||
| 119 | + | client := &subagentContextRecoveryClient{ | |||
| 120 | + | retried: make(chan provider.Request, 1), | |||
| 121 | + | retryOverflow: true, | |||
| 122 | + | } | |||
| 123 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 124 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 125 | + | ||||
| 126 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 127 | + | if !provider.IsContextOverflowError(err) { | |||
| 128 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 129 | + | } | |||
| 130 | + | <-client.retried | |||
| 131 | + | client.mu.Lock() | |||
| 132 | + | calls := client.calls | |||
| 133 | + | client.mu.Unlock() | |||
| 134 | + | if calls != 3 { | |||
| 135 | + | t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) { | |||
| 140 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 141 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 142 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 144 | + | defer cancel() | |||
| 145 | + | ||||
| 146 | + | _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) { | |||
| 147 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 148 | + | cancel() | |||
| 149 | + | } | |||
| 150 | + | }, nil) | |||
| 151 | + | if !provider.IsContextOverflowError(err) { | |||
| 152 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 153 | + | } | |||
| 154 | + | if err := ctx.Err(); !errors.Is(err, context.Canceled) { | |||
| 155 | + | t.Fatalf("context error = %v, want %v", err, context.Canceled) | |||
| 156 | + | } | |||
| 157 | + | client.mu.Lock() | |||
| 158 | + | calls := client.calls | |||
| 159 | + | client.mu.Unlock() | |||
| 160 | + | if calls != 1 { | |||
| 161 | + | t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) { | |||
| 166 | + | client := &subagentContextRecoveryClient{ | |||
| 167 | + | retried: make(chan provider.Request, 1), | |||
| 168 | + | compactionErr: errors.New("compaction provider unavailable"), | |||
| 169 | + | } | |||
| 170 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 171 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 172 | + | ||||
| 173 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 174 | + | if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") { | |||
| 175 | + | t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err) | |||
| 176 | + | } | |||
| 177 | + | payload := resultErrorPayload(err) | |||
| 178 | + | if payload["code"] != "turn_failed" { | |||
| 179 | + | t.Fatalf("result error payload = %#v, want turn_failed", payload) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) { | |||
| 184 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 185 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 186 | + | ||||
| 187 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 188 | + | if !provider.IsContextOverflowError(err) { | |||
| 189 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 190 | + | } | |||
| 191 | + | client.mu.Lock() | |||
| 192 | + | calls := client.calls | |||
| 193 | + | requests := append([]provider.Request(nil), client.requests...) | |||
| 194 | + | client.mu.Unlock() | |||
| 195 | + | if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 { | |||
| 196 | + | t.Fatalf("initial request does not retain the prompt: %#v", requests) | |||
| 197 | + | } | |||
| 198 | + | if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |||
| 199 | + | t.Fatalf("single prompt was not retained: %#v", agent.Messages()) | |||
| 200 | + | } | |||
| 201 | + | if calls != 1 { | |||
| 202 | + | t.Fatalf("provider calls = %d, want 1 without compaction", calls) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) { | |||
| 207 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 208 | + | history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}} | |||
| 209 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |||
| 210 | + | if err != nil { | |||
| 211 | + | t.Fatalf("create session: %v", err) | |||
| 212 | + | } | |||
| 213 | + | if err := session.AppendMessage(history[0]); err != nil { | |||
| 214 | + | t.Fatalf("append history: %v", err) | |||
| 215 | + | } | |||
| 216 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 217 | + | agent.SetMessages(history) | |||
| 218 | + | ||||
| 219 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error { | |||
| 220 | + | return errors.New("persist failed") | |||
| 221 | + | }) | |||
| 222 | + | if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") { | |||
| 223 | + | t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err) | |||
| 224 | + | } | |||
| 225 | + | if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |||
| 226 | + | t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages()) | |||
| 227 | + | } | |||
| 228 | + | WriteNewTranscript(agent, session, outputStart) | |||
| 229 | + | if err := session.Close(); err != nil { | |||
| 230 | + | t.Fatalf("close session: %v", err) | |||
| 231 | + | } | |||
| 232 | + | reopened, messages, err := core.OpenSession(session.Path) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatalf("reopen session: %v", err) | |||
| 235 | + | } | |||
| 236 | + | t.Cleanup(func() { | |||
| 237 | + | if err := reopened.Close(); err != nil { | |||
| 238 | + | t.Errorf("close reopened session: %v", err) | |||
| 239 | + | } | |||
| 240 | + | }) | |||
| 241 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |||
| 242 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |||
| 243 | + | } | |||
| 244 | + | client.mu.Lock() | |||
| 245 | + | calls := client.calls | |||
| 246 | + | client.mu.Unlock() | |||
| 247 | + | if calls != 2 { | |||
| 248 | + | t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) { | |||
| 253 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 254 | + | history := []provider.Message{ | |||
| 255 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 256 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 257 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 258 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 259 | + | } | |||
| 260 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |||
| 261 | + | if err != nil { | |||
| 262 | + | t.Fatalf("create session: %v", err) | |||
| 263 | + | } | |||
| 264 | + | for _, message := range history { | |||
| 265 | + | if err := session.AppendMessage(message); err != nil { | |||
| 266 | + | t.Fatalf("append history: %v", err) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 271 | + | agent.SetMessages(history) | |||
| 272 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction) | |||
| 273 | + | if err != nil { | |||
| 274 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |||
| 275 | + | } | |||
| 276 | + | WriteNewTranscript(agent, session, outputStart) | |||
| 277 | + | if err := session.Close(); err != nil { | |||
| 278 | + | t.Fatalf("close session: %v", err) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | records, err := os.ReadFile(session.Path) | |||
| 282 | + | if err != nil { | |||
| 283 | + | t.Fatalf("read session: %v", err) | |||
| 284 | + | } | |||
| 285 | + | compactions := 0 | |||
| 286 | + | for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) { | |||
| 287 | + | var entry struct { | |||
| 288 | + | Type string `json:"type"` | |||
| 289 | + | } | |||
| 290 | + | if err := json.Unmarshal(record, &entry); err != nil { | |||
| 291 | + | t.Fatalf("decode session record: %v", err) | |||
| 292 | + | } | |||
| 293 | + | if entry.Type == "compaction" { | |||
| 294 | + | compactions++ | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | if compactions != 1 { | |||
| 298 | + | t.Fatalf("compaction records = %d, want 1", compactions) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | reopened, messages, err := core.OpenSession(session.Path) | |||
| 302 | + | if err != nil { | |||
| 303 | + | t.Fatalf("reopen session: %v", err) | |||
| 304 | + | } | |||
| 305 | + | t.Cleanup(func() { | |||
| 306 | + | if err := reopened.Close(); err != nil { | |||
| 307 | + | t.Errorf("close reopened session: %v", err) | |||
| 308 | + | } | |||
| 309 | + | }) | |||
| 310 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |||
| 311 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |||
| 312 | + | } | |||
| 313 | + | if got := finalAssistantText(messages); got != "two\nfour\nrecovered" { | |||
| 314 | + | t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func workerRequestMessageTextCount(messages []provider.Message, want string) int { | |||
| 319 | + | count := 0 | |||
| 320 | + | for _, message := range messages { | |||
| 321 | + | for _, block := range message.Content { | |||
| 322 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |||
| 323 | + | count++ | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return count | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func workerRequestContainsText(req provider.Request, want string) bool { | |||
| 331 | + | for _, message := range req.Messages { | |||
| 332 | + | for _, block := range message.Content { | |||
| 333 | + | if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) { | |||
| 334 | + | return true | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | return false | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func workerRequestUserTextCount(req provider.Request, want string) int { | |||
| 342 | + | count := 0 | |||
| 343 | + | for _, message := range req.Messages { | |||
| 344 | + | if message.Role != provider.RoleUser { | |||
| 345 | + | continue | |||
| 346 | + | } | |||
| 347 | + | for _, block := range message.Content { | |||
| 348 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |||
| 349 | + | count++ | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return count | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) { | |||
| 357 | + | payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text")) | |||
| 358 | + | if payload["code"] != "context_limit" { | |||
| 359 | + | t.Fatalf("error code = %q, want context_limit", payload["code"]) | |||
| 360 | + | } | |||
| 361 | + | if payload["message"] != subagentContextLimitMessage { | |||
| 362 | + | t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |||
| 367 | + | payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")}) | |||
| 368 | + | if payload["error"] != subagentContextLimitMessage { | |||
| 369 | + | t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 33 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | 373 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | ||
| 34 | // "everything is doubled after reopening a subagent agent" bug. | 374 | // "everything is doubled after reopening a subagent agent" bug. | ||
| 35 | // | 375 | // | ||
M packages/core/agent.go +55 -4
| @@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 474 | 474 | assistantMsg provider.Message | |
| 475 | 475 | err error | |
| 476 | 476 | ) | |
| 477 | + | maxAttempts := max(a.MaxRetries+1, 1) | |
| 477 | 478 | for attempt := 0; ; attempt++ { | |
| 478 | - | stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext) | |
| 479 | + | stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext, attempt, maxAttempts) | |
| 479 | 480 | sink(EvTurnEnd{Stop: stop, Err: err}) | |
| 480 | 481 | if err == nil || !a.canRetryError(err, attempt) { | |
| 481 | 482 | break | |
| 482 | 483 | } | |
| 483 | 484 | a.dropLastAssistantMessage() | |
| 484 | - | if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil { | |
| 485 | + | delay := a.retryDelay(attempt) | |
| 486 | + | if retryErr := ctx.Err(); retryErr != nil { | |
| 487 | + | return retryErr | |
| 488 | + | } | |
| 489 | + | sink(EvRetryScheduled{ | |
| 490 | + | Scope: RetryScopeAgent, | |
| 491 | + | Attempt: attempt + 2, | |
| 492 | + | MaxAttempts: maxAttempts, | |
| 493 | + | Delay: delay, | |
| 494 | + | }) | |
| 495 | + | if sleepErr := sleepRetry(ctx, delay); sleepErr != nil { | |
| 485 | 496 | return sleepErr | |
| 486 | 497 | } | |
| 487 | 498 | } | |
| @@ -633,9 +644,36 @@ func (a *Agent) dropLastAssistantMessage() { | |||
| 633 | 644 | } | |
| 634 | 645 | } | |
| 635 | 646 | ||
| 647 | + | // requestLifecycleSink adapts provider-owned pre-stream retries into core | |
| 648 | + | // events without letting provider concerns leak into mode renderers. | |
| 649 | + | type requestLifecycleSink struct { | |
| 650 | + | sink func(AgentEvent) | |
| 651 | + | provider string | |
| 652 | + | model string | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func (s requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | |
| 656 | + | s.sink(EvRequestStarted{ | |
| 657 | + | Provider: s.provider, | |
| 658 | + | Model: s.model, | |
| 659 | + | Scope: RetryScopeProvider, | |
| 660 | + | Attempt: attempt, | |
| 661 | + | MaxAttempts: maxAttempts, | |
| 662 | + | }) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func (s requestLifecycleSink) RetryScheduled(attempt, maxAttempts int, delay time.Duration) { | |
| 666 | + | s.sink(EvRetryScheduled{ | |
| 667 | + | Scope: RetryScopeProvider, | |
| 668 | + | Attempt: attempt, | |
| 669 | + | MaxAttempts: maxAttempts, | |
| 670 | + | Delay: delay, | |
| 671 | + | }) | |
| 672 | + | } | |
| 673 | + | ||
| 636 | 674 | // oneTurn calls the LLM once, forwards events, returns the stop reason | |
| 637 | 675 | // and the assembled assistant message (already appended to the transcript). | |
| 638 | - | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) { | |
| 676 | + | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string, attempt, maxAttempts int) (provider.StopReason, provider.Message, error) { | |
| 639 | 677 | fastMode := a.FastModeEnabled() | |
| 640 | 678 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |
| 641 | 679 | return provider.StopError, provider.Message{}, err | |
| @@ -664,7 +702,19 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 664 | 702 | FastMode: fastMode, | |
| 665 | 703 | MaxTokens: a.MaxTokens, | |
| 666 | 704 | Temperature: a.Temperature, | |
| 667 | - | } | |
| 705 | + | Lifecycle: requestLifecycleSink{ | |
| 706 | + | sink: sink, | |
| 707 | + | provider: a.Client.Name(), | |
| 708 | + | model: a.Model, | |
| 709 | + | }, | |
| 710 | + | } | |
| 711 | + | sink(EvRequestStarted{ | |
| 712 | + | Provider: a.Client.Name(), | |
| 713 | + | Model: a.Model, | |
| 714 | + | Scope: RetryScopeAgent, | |
| 715 | + | Attempt: attempt + 1, | |
| 716 | + | MaxAttempts: maxAttempts, | |
| 717 | + | }) | |
| 668 | 718 | stream, err := a.Client.Stream(ctx, req) | |
| 669 | 719 | if err != nil { | |
| 670 | 720 | return provider.StopError, provider.Message{}, err | |
| @@ -852,6 +902,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools | |||
| 852 | 902 | } | |
| 853 | 903 | } | |
| 854 | 904 | }() | |
| 905 | + | sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name}) | |
| 855 | 906 | out, err := tool.Execute(ctx, args, func(text string) { | |
| 856 | 907 | sink(EvToolProgress{ID: tc.ID, Text: text}) | |
| 857 | 908 | }) | |
| @@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||||
| 474 | assistantMsg provider.Message | 474 | assistantMsg provider.Message | ||
| 475 | err error | 475 | err error | ||
| 476 | ) | 476 | ) | ||
| 477 | + | maxAttempts := max(a.MaxRetries+1, 1) | |||
| 477 | for attempt := 0; ; attempt++ { | 478 | for attempt := 0; ; attempt++ { | ||
| 478 | - | stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext) | 479 | + | stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext, attempt, maxAttempts) |
| 479 | sink(EvTurnEnd{Stop: stop, Err: err}) | 480 | sink(EvTurnEnd{Stop: stop, Err: err}) | ||
| 480 | if err == nil || !a.canRetryError(err, attempt) { | 481 | if err == nil || !a.canRetryError(err, attempt) { | ||
| 481 | break | 482 | break | ||
| 482 | } | 483 | } | ||
| 483 | a.dropLastAssistantMessage() | 484 | a.dropLastAssistantMessage() | ||
| 484 | - | if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil { | 485 | + | delay := a.retryDelay(attempt) |
| 486 | + | if retryErr := ctx.Err(); retryErr != nil { | |||
| 487 | + | return retryErr | |||
| 488 | + | } | |||
| 489 | + | sink(EvRetryScheduled{ | |||
| 490 | + | Scope: RetryScopeAgent, | |||
| 491 | + | Attempt: attempt + 2, | |||
| 492 | + | MaxAttempts: maxAttempts, | |||
| 493 | + | Delay: delay, | |||
| 494 | + | }) | |||
| 495 | + | if sleepErr := sleepRetry(ctx, delay); sleepErr != nil { | |||
| 485 | return sleepErr | 496 | return sleepErr | ||
| 486 | } | 497 | } | ||
| 487 | } | 498 | } | ||
| @@ -633,9 +644,36 @@ func (a *Agent) dropLastAssistantMessage() { | |||||
| 633 | } | 644 | } | ||
| 634 | } | 645 | } | ||
| 635 | 646 | ||||
| 647 | + | // requestLifecycleSink adapts provider-owned pre-stream retries into core | |||
| 648 | + | // events without letting provider concerns leak into mode renderers. | |||
| 649 | + | type requestLifecycleSink struct { | |||
| 650 | + | sink func(AgentEvent) | |||
| 651 | + | provider string | |||
| 652 | + | model string | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func (s requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) { | |||
| 656 | + | s.sink(EvRequestStarted{ | |||
| 657 | + | Provider: s.provider, | |||
| 658 | + | Model: s.model, | |||
| 659 | + | Scope: RetryScopeProvider, | |||
| 660 | + | Attempt: attempt, | |||
| 661 | + | MaxAttempts: maxAttempts, | |||
| 662 | + | }) | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | func (s requestLifecycleSink) RetryScheduled(attempt, maxAttempts int, delay time.Duration) { | |||
| 666 | + | s.sink(EvRetryScheduled{ | |||
| 667 | + | Scope: RetryScopeProvider, | |||
| 668 | + | Attempt: attempt, | |||
| 669 | + | MaxAttempts: maxAttempts, | |||
| 670 | + | Delay: delay, | |||
| 671 | + | }) | |||
| 672 | + | } | |||
| 673 | + | ||||
| 636 | // oneTurn calls the LLM once, forwards events, returns the stop reason | 674 | // oneTurn calls the LLM once, forwards events, returns the stop reason | ||
| 637 | // and the assembled assistant message (already appended to the transcript). | 675 | // and the assembled assistant message (already appended to the transcript). | ||
| 638 | - | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) { | 676 | + | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string, attempt, maxAttempts int) (provider.StopReason, provider.Message, error) { |
| 639 | fastMode := a.FastModeEnabled() | 677 | fastMode := a.FastModeEnabled() | ||
| 640 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | 678 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | ||
| 641 | return provider.StopError, provider.Message{}, err | 679 | return provider.StopError, provider.Message{}, err | ||
| @@ -664,7 +702,19 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 664 | FastMode: fastMode, | 702 | FastMode: fastMode, | ||
| 665 | MaxTokens: a.MaxTokens, | 703 | MaxTokens: a.MaxTokens, | ||
| 666 | Temperature: a.Temperature, | 704 | Temperature: a.Temperature, | ||
| 667 | - | } | 705 | + | Lifecycle: requestLifecycleSink{ |
| 706 | + | sink: sink, | |||
| 707 | + | provider: a.Client.Name(), | |||
| 708 | + | model: a.Model, | |||
| 709 | + | }, | |||
| 710 | + | } | |||
| 711 | + | sink(EvRequestStarted{ | |||
| 712 | + | Provider: a.Client.Name(), | |||
| 713 | + | Model: a.Model, | |||
| 714 | + | Scope: RetryScopeAgent, | |||
| 715 | + | Attempt: attempt + 1, | |||
| 716 | + | MaxAttempts: maxAttempts, | |||
| 717 | + | }) | |||
| 668 | stream, err := a.Client.Stream(ctx, req) | 718 | stream, err := a.Client.Stream(ctx, req) | ||
| 669 | if err != nil { | 719 | if err != nil { | ||
| 670 | return provider.StopError, provider.Message{}, err | 720 | return provider.StopError, provider.Message{}, err | ||
| @@ -852,6 +902,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools | |||||
| 852 | } | 902 | } | ||
| 853 | } | 903 | } | ||
| 854 | }() | 904 | }() | ||
| 905 | + | sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name}) | |||
| 855 | out, err := tool.Execute(ctx, args, func(text string) { | 906 | out, err := tool.Execute(ctx, args, func(text string) { | ||
| 856 | sink(EvToolProgress{ID: tc.ID, Text: text}) | 907 | sink(EvToolProgress{ID: tc.ID, Text: text}) | ||
| 857 | }) | 908 | }) | ||
M packages/core/agent_retry_test.go +66 -0
| @@ -20,6 +20,9 @@ type retryFakeClient struct { | |||
| 20 | 20 | func (c *retryFakeClient) Name() string { return "retry-fake" } | |
| 21 | 21 | ||
| 22 | 22 | func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 23 | + | if req.Lifecycle != nil { | |
| 24 | + | req.Lifecycle.RequestAttempt(1, 1) | |
| 25 | + | } | |
| 23 | 26 | call := atomic.AddInt32(&c.calls, 1) | |
| 24 | 27 | out := make(chan provider.Event, 4) | |
| 25 | 28 | go func() { | |
| @@ -67,6 +70,35 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) { | |||
| 67 | 70 | } | |
| 68 | 71 | } | |
| 69 | 72 | ||
| 73 | + | func TestAgentEmitsRetryLifecycleEvents(t *testing.T) { | |
| 74 | + | client := &retryFakeClient{} | |
| 75 | + | a := NewAgent(client, "fake-model", "system", Registry{}) | |
| 76 | + | a.RetryBaseDelay = time.Millisecond | |
| 77 | + | ||
| 78 | + | var lifecycle []string | |
| 79 | + | if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) { | |
| 80 | + | switch e := ev.(type) { | |
| 81 | + | case EvRequestStarted: | |
| 82 | + | lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts)) | |
| 83 | + | case EvRetryScheduled: | |
| 84 | + | lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts)) | |
| 85 | + | } | |
| 86 | + | }); err != nil { | |
| 87 | + | t.Fatalf("Prompt returned %v", err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | want := []string{ | |
| 91 | + | "request:agent:1/4", | |
| 92 | + | "request:provider:1/1", | |
| 93 | + | "retry:agent:2/4", | |
| 94 | + | "request:agent:2/4", | |
| 95 | + | "request:provider:1/1", | |
| 96 | + | } | |
| 97 | + | if strings.Join(lifecycle, ",") != strings.Join(want, ",") { | |
| 98 | + | t.Fatalf("lifecycle = %v, want %v", lifecycle, want) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 70 | 102 | // codexRetryFakeClient reproduces a transient OpenAI Codex backend | |
| 71 | 103 | // failure on the first call and succeeds afterwards. | |
| 72 | 104 | type codexRetryFakeClient struct { | |
| @@ -288,3 +320,37 @@ func TestAgentPropagatesTemperature(t *testing.T) { | |||
| 288 | 320 | t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp) | |
| 289 | 321 | } | |
| 290 | 322 | } | |
| 323 | + | ||
| 324 | + | type cancelledRetryClient struct { | |
| 325 | + | cancel context.CancelFunc | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (c *cancelledRetryClient) Name() string { return "cancelled-retry" } | |
| 329 | + | ||
| 330 | + | func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 331 | + | out := make(chan provider.Event, 1) | |
| 332 | + | c.cancel() | |
| 333 | + | out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")} | |
| 334 | + | close(out) | |
| 335 | + | return out, nil | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) { | |
| 339 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 340 | + | client := &cancelledRetryClient{cancel: cancel} | |
| 341 | + | agent := NewAgent(client, "fake-model", "system", Registry{}) | |
| 342 | + | agent.MaxRetries = 1 | |
| 343 | + | ||
| 344 | + | var retries []EvRetryScheduled | |
| 345 | + | err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) { | |
| 346 | + | if retry, ok := event.(EvRetryScheduled); ok { | |
| 347 | + | retries = append(retries, retry) | |
| 348 | + | } | |
| 349 | + | }) | |
| 350 | + | if !errors.Is(err, context.Canceled) { | |
| 351 | + | t.Fatalf("Prompt error = %v, want context.Canceled", err) | |
| 352 | + | } | |
| 353 | + | if len(retries) != 0 { | |
| 354 | + | t.Fatalf("retry events = %#v, want none after cancellation", retries) | |
| 355 | + | } | |
| 356 | + | } | |
| @@ -20,6 +20,9 @@ type retryFakeClient struct { | |||||
| 20 | func (c *retryFakeClient) Name() string { return "retry-fake" } | 20 | func (c *retryFakeClient) Name() string { return "retry-fake" } | ||
| 21 | 21 | ||||
| 22 | func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | 22 | func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | ||
| 23 | + | if req.Lifecycle != nil { | |||
| 24 | + | req.Lifecycle.RequestAttempt(1, 1) | |||
| 25 | + | } | |||
| 23 | call := atomic.AddInt32(&c.calls, 1) | 26 | call := atomic.AddInt32(&c.calls, 1) | ||
| 24 | out := make(chan provider.Event, 4) | 27 | out := make(chan provider.Event, 4) | ||
| 25 | go func() { | 28 | go func() { | ||
| @@ -67,6 +70,35 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) { | |||||
| 67 | } | 70 | } | ||
| 68 | } | 71 | } | ||
| 69 | 72 | ||||
| 73 | + | func TestAgentEmitsRetryLifecycleEvents(t *testing.T) { | |||
| 74 | + | client := &retryFakeClient{} | |||
| 75 | + | a := NewAgent(client, "fake-model", "system", Registry{}) | |||
| 76 | + | a.RetryBaseDelay = time.Millisecond | |||
| 77 | + | ||||
| 78 | + | var lifecycle []string | |||
| 79 | + | if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) { | |||
| 80 | + | switch e := ev.(type) { | |||
| 81 | + | case EvRequestStarted: | |||
| 82 | + | lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts)) | |||
| 83 | + | case EvRetryScheduled: | |||
| 84 | + | lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts)) | |||
| 85 | + | } | |||
| 86 | + | }); err != nil { | |||
| 87 | + | t.Fatalf("Prompt returned %v", err) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | want := []string{ | |||
| 91 | + | "request:agent:1/4", | |||
| 92 | + | "request:provider:1/1", | |||
| 93 | + | "retry:agent:2/4", | |||
| 94 | + | "request:agent:2/4", | |||
| 95 | + | "request:provider:1/1", | |||
| 96 | + | } | |||
| 97 | + | if strings.Join(lifecycle, ",") != strings.Join(want, ",") { | |||
| 98 | + | t.Fatalf("lifecycle = %v, want %v", lifecycle, want) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 70 | // codexRetryFakeClient reproduces a transient OpenAI Codex backend | 102 | // codexRetryFakeClient reproduces a transient OpenAI Codex backend | ||
| 71 | // failure on the first call and succeeds afterwards. | 103 | // failure on the first call and succeeds afterwards. | ||
| 72 | type codexRetryFakeClient struct { | 104 | type codexRetryFakeClient struct { | ||
| @@ -288,3 +320,37 @@ func TestAgentPropagatesTemperature(t *testing.T) { | |||||
| 288 | t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp) | 320 | t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp) | ||
| 289 | } | 321 | } | ||
| 290 | } | 322 | } | ||
| 323 | + | ||||
| 324 | + | type cancelledRetryClient struct { | |||
| 325 | + | cancel context.CancelFunc | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (c *cancelledRetryClient) Name() string { return "cancelled-retry" } | |||
| 329 | + | ||||
| 330 | + | func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 331 | + | out := make(chan provider.Event, 1) | |||
| 332 | + | c.cancel() | |||
| 333 | + | out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")} | |||
| 334 | + | close(out) | |||
| 335 | + | return out, nil | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) { | |||
| 339 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 340 | + | client := &cancelledRetryClient{cancel: cancel} | |||
| 341 | + | agent := NewAgent(client, "fake-model", "system", Registry{}) | |||
| 342 | + | agent.MaxRetries = 1 | |||
| 343 | + | ||||
| 344 | + | var retries []EvRetryScheduled | |||
| 345 | + | err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) { | |||
| 346 | + | if retry, ok := event.(EvRetryScheduled); ok { | |||
| 347 | + | retries = append(retries, retry) | |||
| 348 | + | } | |||
| 349 | + | }) | |||
| 350 | + | if !errors.Is(err, context.Canceled) { | |||
| 351 | + | t.Fatalf("Prompt error = %v, want context.Canceled", err) | |||
| 352 | + | } | |||
| 353 | + | if len(retries) != 0 { | |||
| 354 | + | t.Fatalf("retry events = %#v, want none after cancellation", retries) | |||
| 355 | + | } | |||
| 356 | + | } | |||
M packages/core/compact.go +36 -5
| @@ -18,10 +18,21 @@ import ( | |||
| 18 | 18 | // the summary. 0 means summarize everything; a typical useful value is | |
| 19 | 19 | // 4-8 (last couple of exchanges). | |
| 20 | 20 | // | |
| 21 | - | // The method blocks until the summary request completes. Emitted | |
| 22 | - | // events via sink are limited to text deltas from the summary call so | |
| 23 | - | // the UI can show progress. | |
| 21 | + | // The method blocks until the summary request completes. The optional sink | |
| 22 | + | // receives text deltas from the summary call. | |
| 24 | 23 | func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) { | |
| 24 | + | return a.compact(ctx, keepTail, sink, nil) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // CompactWithEvents is Compact for consumers that need factual provider | |
| 28 | + | // lifecycle observations in addition to the final summary. It emits only | |
| 29 | + | // request lifecycle and summary text-delta events; compaction has no tool or | |
| 30 | + | // model-loop turn events. | |
| 31 | + | func (a *Agent) CompactWithEvents(ctx context.Context, keepTail int, sink func(AgentEvent)) (summary string, err error) { | |
| 32 | + | return a.compact(ctx, keepTail, nil, sink) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta string), eventSink func(AgentEvent)) (summary string, err error) { | |
| 25 | 36 | a.mu.Lock() | |
| 26 | 37 | msgs := append([]provider.Message(nil), a.messages...) | |
| 27 | 38 | a.mu.Unlock() | |
| @@ -65,19 +76,39 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||
| 65 | 76 | }, | |
| 66 | 77 | }, | |
| 67 | 78 | } | |
| 79 | + | if eventSink != nil { | |
| 80 | + | req.Lifecycle = requestLifecycleSink{ | |
| 81 | + | sink: eventSink, | |
| 82 | + | provider: a.Client.Name(), | |
| 83 | + | model: a.Model, | |
| 84 | + | } | |
| 85 | + | eventSink(EvRequestStarted{ | |
| 86 | + | Provider: a.Client.Name(), | |
| 87 | + | Model: a.Model, | |
| 88 | + | Scope: RetryScopeAgent, | |
| 89 | + | Attempt: 1, | |
| 90 | + | MaxAttempts: 1, | |
| 91 | + | }) | |
| 92 | + | } | |
| 68 | 93 | ||
| 69 | 94 | stream, err := a.Client.Stream(ctx, req) | |
| 70 | 95 | if err != nil { | |
| 71 | 96 | return "", err | |
| 72 | 97 | } | |
| 98 | + | if eventSink != nil { | |
| 99 | + | eventSink(EvAssistantStart{}) | |
| 100 | + | } | |
| 73 | 101 | ||
| 74 | 102 | var sb strings.Builder | |
| 75 | 103 | for ev := range stream { | |
| 76 | 104 | switch e := ev.(type) { | |
| 77 | 105 | case provider.EventTextDelta: | |
| 78 | 106 | sb.WriteString(e.Delta) | |
| 79 | - | if sink != nil { | |
| 80 | - | sink(e.Delta) | |
| 107 | + | if textSink != nil { | |
| 108 | + | textSink(e.Delta) | |
| 109 | + | } | |
| 110 | + | if eventSink != nil { | |
| 111 | + | eventSink(EvTextDelta{Delta: e.Delta}) | |
| 81 | 112 | } | |
| 82 | 113 | case provider.EventDone: | |
| 83 | 114 | if e.Err != nil { | |
| @@ -18,10 +18,21 @@ import ( | |||||
| 18 | // the summary. 0 means summarize everything; a typical useful value is | 18 | // the summary. 0 means summarize everything; a typical useful value is | ||
| 19 | // 4-8 (last couple of exchanges). | 19 | // 4-8 (last couple of exchanges). | ||
| 20 | // | 20 | // | ||
| 21 | - | // The method blocks until the summary request completes. Emitted | 21 | + | // The method blocks until the summary request completes. The optional sink |
| 22 | - | // events via sink are limited to text deltas from the summary call so | 22 | + | // receives text deltas from the summary call. |
| 23 | - | // the UI can show progress. | |||
| 24 | func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) { | 23 | func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) { | ||
| 24 | + | return a.compact(ctx, keepTail, sink, nil) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // CompactWithEvents is Compact for consumers that need factual provider | |||
| 28 | + | // lifecycle observations in addition to the final summary. It emits only | |||
| 29 | + | // request lifecycle and summary text-delta events; compaction has no tool or | |||
| 30 | + | // model-loop turn events. | |||
| 31 | + | func (a *Agent) CompactWithEvents(ctx context.Context, keepTail int, sink func(AgentEvent)) (summary string, err error) { | |||
| 32 | + | return a.compact(ctx, keepTail, nil, sink) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta string), eventSink func(AgentEvent)) (summary string, err error) { | |||
| 25 | a.mu.Lock() | 36 | a.mu.Lock() | ||
| 26 | msgs := append([]provider.Message(nil), a.messages...) | 37 | msgs := append([]provider.Message(nil), a.messages...) | ||
| 27 | a.mu.Unlock() | 38 | a.mu.Unlock() | ||
| @@ -65,19 +76,39 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||||
| 65 | }, | 76 | }, | ||
| 66 | }, | 77 | }, | ||
| 67 | } | 78 | } | ||
| 79 | + | if eventSink != nil { | |||
| 80 | + | req.Lifecycle = requestLifecycleSink{ | |||
| 81 | + | sink: eventSink, | |||
| 82 | + | provider: a.Client.Name(), | |||
| 83 | + | model: a.Model, | |||
| 84 | + | } | |||
| 85 | + | eventSink(EvRequestStarted{ | |||
| 86 | + | Provider: a.Client.Name(), | |||
| 87 | + | Model: a.Model, | |||
| 88 | + | Scope: RetryScopeAgent, | |||
| 89 | + | Attempt: 1, | |||
| 90 | + | MaxAttempts: 1, | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 68 | 93 | ||||
| 69 | stream, err := a.Client.Stream(ctx, req) | 94 | stream, err := a.Client.Stream(ctx, req) | ||
| 70 | if err != nil { | 95 | if err != nil { | ||
| 71 | return "", err | 96 | return "", err | ||
| 72 | } | 97 | } | ||
| 98 | + | if eventSink != nil { | |||
| 99 | + | eventSink(EvAssistantStart{}) | |||
| 100 | + | } | |||
| 73 | 101 | ||||
| 74 | var sb strings.Builder | 102 | var sb strings.Builder | ||
| 75 | for ev := range stream { | 103 | for ev := range stream { | ||
| 76 | switch e := ev.(type) { | 104 | switch e := ev.(type) { | ||
| 77 | case provider.EventTextDelta: | 105 | case provider.EventTextDelta: | ||
| 78 | sb.WriteString(e.Delta) | 106 | sb.WriteString(e.Delta) | ||
| 79 | - | if sink != nil { | 107 | + | if textSink != nil { |
| 80 | - | sink(e.Delta) | 108 | + | textSink(e.Delta) |
| 109 | + | } | |||
| 110 | + | if eventSink != nil { | |||
| 111 | + | eventSink(EvTextDelta{Delta: e.Delta}) | |||
| 81 | } | 112 | } | ||
| 82 | case provider.EventDone: | 113 | case provider.EventDone: | ||
| 83 | if e.Err != nil { | 114 | if e.Err != nil { | ||
A packages/core/compact_lifecycle_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type compactLifecycleClient struct { | |
| 11 | + | hadLifecycle bool | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" } | |
| 15 | + | ||
| 16 | + | func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 17 | + | c.hadLifecycle = req.Lifecycle != nil | |
| 18 | + | if req.Lifecycle != nil { | |
| 19 | + | req.Lifecycle.RequestAttempt(1, 1) | |
| 20 | + | } | |
| 21 | + | out := make(chan provider.Event, 2) | |
| 22 | + | out <- provider.EventTextDelta{Delta: "summary"} | |
| 23 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 24 | + | Role: provider.RoleAssistant, | |
| 25 | + | Content: []provider.Content{provider.TextBlock{Text: "summary"}}, | |
| 26 | + | }} | |
| 27 | + | close(out) | |
| 28 | + | return out, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) { | |
| 32 | + | client := &compactLifecycleClient{} | |
| 33 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| 34 | + | agent.SetMessages([]provider.Message{{ | |
| 35 | + | Role: provider.RoleUser, | |
| 36 | + | Content: []provider.Content{provider.TextBlock{Text: "source"}}, | |
| 37 | + | }}) | |
| 38 | + | ||
| 39 | + | var events []AgentEvent | |
| 40 | + | summary, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) { | |
| 41 | + | events = append(events, event) | |
| 42 | + | }) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("CompactWithEvents: %v", err) | |
| 45 | + | } | |
| 46 | + | if summary != "summary" { | |
| 47 | + | t.Fatalf("summary = %q, want summary", summary) | |
| 48 | + | } | |
| 49 | + | if !client.hadLifecycle { | |
| 50 | + | t.Fatal("compaction request did not receive a lifecycle observer") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if len(events) != 4 { | |
| 54 | + | t.Fatalf("events = %#v, want agent request, provider request, assistant start, text delta", events) | |
| 55 | + | } | |
| 56 | + | agentRequest, ok := events[0].(EvRequestStarted) | |
| 57 | + | if !ok || agentRequest.Scope != RetryScopeAgent || agentRequest.Attempt != 1 || agentRequest.MaxAttempts != 1 { | |
| 58 | + | t.Fatalf("first event = %#v, want initial agent request", events[0]) | |
| 59 | + | } | |
| 60 | + | providerRequest, ok := events[1].(EvRequestStarted) | |
| 61 | + | if !ok || providerRequest.Scope != RetryScopeProvider || providerRequest.Attempt != 1 || providerRequest.MaxAttempts != 1 { | |
| 62 | + | t.Fatalf("second event = %#v, want initial provider request", events[1]) | |
| 63 | + | } | |
| 64 | + | if _, ok := events[2].(EvAssistantStart); !ok { | |
| 65 | + | t.Fatalf("third event = %#v, want assistant start", events[2]) | |
| 66 | + | } | |
| 67 | + | if delta, ok := events[3].(EvTextDelta); !ok || delta.Delta != "summary" { | |
| 68 | + | t.Fatalf("fourth event = %#v, want summary text delta", events[3]) | |
| 69 | + | } | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type compactLifecycleClient struct { | |||
| 11 | + | hadLifecycle bool | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" } | |||
| 15 | + | ||||
| 16 | + | func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 17 | + | c.hadLifecycle = req.Lifecycle != nil | |||
| 18 | + | if req.Lifecycle != nil { | |||
| 19 | + | req.Lifecycle.RequestAttempt(1, 1) | |||
| 20 | + | } | |||
| 21 | + | out := make(chan provider.Event, 2) | |||
| 22 | + | out <- provider.EventTextDelta{Delta: "summary"} | |||
| 23 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 24 | + | Role: provider.RoleAssistant, | |||
| 25 | + | Content: []provider.Content{provider.TextBlock{Text: "summary"}}, | |||
| 26 | + | }} | |||
| 27 | + | close(out) | |||
| 28 | + | return out, nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) { | |||
| 32 | + | client := &compactLifecycleClient{} | |||
| 33 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |||
| 34 | + | agent.SetMessages([]provider.Message{{ | |||
| 35 | + | Role: provider.RoleUser, | |||
| 36 | + | Content: []provider.Content{provider.TextBlock{Text: "source"}}, | |||
| 37 | + | }}) | |||
| 38 | + | ||||
| 39 | + | var events []AgentEvent | |||
| 40 | + | summary, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) { | |||
| 41 | + | events = append(events, event) | |||
| 42 | + | }) | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Fatalf("CompactWithEvents: %v", err) | |||
| 45 | + | } | |||
| 46 | + | if summary != "summary" { | |||
| 47 | + | t.Fatalf("summary = %q, want summary", summary) | |||
| 48 | + | } | |||
| 49 | + | if !client.hadLifecycle { | |||
| 50 | + | t.Fatal("compaction request did not receive a lifecycle observer") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if len(events) != 4 { | |||
| 54 | + | t.Fatalf("events = %#v, want agent request, provider request, assistant start, text delta", events) | |||
| 55 | + | } | |||
| 56 | + | agentRequest, ok := events[0].(EvRequestStarted) | |||
| 57 | + | if !ok || agentRequest.Scope != RetryScopeAgent || agentRequest.Attempt != 1 || agentRequest.MaxAttempts != 1 { | |||
| 58 | + | t.Fatalf("first event = %#v, want initial agent request", events[0]) | |||
| 59 | + | } | |||
| 60 | + | providerRequest, ok := events[1].(EvRequestStarted) | |||
| 61 | + | if !ok || providerRequest.Scope != RetryScopeProvider || providerRequest.Attempt != 1 || providerRequest.MaxAttempts != 1 { | |||
| 62 | + | t.Fatalf("second event = %#v, want initial provider request", events[1]) | |||
| 63 | + | } | |||
| 64 | + | if _, ok := events[2].(EvAssistantStart); !ok { | |||
| 65 | + | t.Fatalf("third event = %#v, want assistant start", events[2]) | |||
| 66 | + | } | |||
| 67 | + | if delta, ok := events[3].(EvTextDelta); !ok || delta.Delta != "summary" { | |||
| 68 | + | t.Fatalf("fourth event = %#v, want summary text delta", events[3]) | |||
| 69 | + | } | |||
| 70 | + | } | |||
M packages/core/confirm.go +3 -0
| @@ -44,6 +44,9 @@ type ToolCallConfirmation struct { | |||
| 44 | 44 | Name string | |
| 45 | 45 | Summary string | |
| 46 | 46 | Content string | |
| 47 | + | // Origin routes a confirmation to its owning host view. It is opaque to | |
| 48 | + | // the confirmation gate and never shown to the model. | |
| 49 | + | Origin string | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | // ToolCallConfirmer is optionally implemented by confirmation UIs that can | |
| @@ -44,6 +44,9 @@ type ToolCallConfirmation struct { | |||||
| 44 | Name string | 44 | Name string | ||
| 45 | Summary string | 45 | Summary string | ||
| 46 | Content string | 46 | Content string | ||
| 47 | + | // Origin routes a confirmation to its owning host view. It is opaque to | |||
| 48 | + | // the confirmation gate and never shown to the model. | |||
| 49 | + | Origin string | |||
| 47 | } | 50 | } | ||
| 48 | 51 | ||||
| 49 | // ToolCallConfirmer is optionally implemented by confirmation UIs that can | 52 | // ToolCallConfirmer is optionally implemented by confirmation UIs that can | ||
M packages/core/events.go +39 -0
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/zut/packages/provider" | |
| 7 | 8 | ) | |
| @@ -29,6 +30,35 @@ type EvAssistantStart struct{} | |||
| 29 | 30 | ||
| 30 | 31 | func (EvAssistantStart) Type() string { return "assistant_start" } | |
| 31 | 32 | ||
| 33 | + | // RetryScope identifies the retry layer that issued an activity event. | |
| 34 | + | type RetryScope string | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | RetryScopeProvider RetryScope = "provider" | |
| 38 | + | RetryScopeAgent RetryScope = "agent" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // EvRequestStarted reports an outbound provider request attempt. | |
| 42 | + | type EvRequestStarted struct { | |
| 43 | + | Provider string | |
| 44 | + | Model string | |
| 45 | + | Scope RetryScope | |
| 46 | + | Attempt int | |
| 47 | + | MaxAttempts int | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (EvRequestStarted) Type() string { return "request_started" } | |
| 51 | + | ||
| 52 | + | // EvRetryScheduled reports a retry delay before its upcoming attempt. | |
| 53 | + | type EvRetryScheduled struct { | |
| 54 | + | Scope RetryScope | |
| 55 | + | Attempt int | |
| 56 | + | MaxAttempts int | |
| 57 | + | Delay time.Duration | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (EvRetryScheduled) Type() string { return "retry_scheduled" } | |
| 61 | + | ||
| 32 | 62 | type EvTextDelta struct { | |
| 33 | 63 | Delta string | |
| 34 | 64 | } | |
| @@ -43,6 +73,15 @@ type EvToolCall struct { | |||
| 43 | 73 | ||
| 44 | 74 | func (EvToolCall) Type() string { return "tool_call" } | |
| 45 | 75 | ||
| 76 | + | // EvToolExecutionStarted fires after guards and confirmation allow a call, | |
| 77 | + | // immediately before the tool starts executing. | |
| 78 | + | type EvToolExecutionStarted struct { | |
| 79 | + | ID string | |
| 80 | + | Name string | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (EvToolExecutionStarted) Type() string { return "tool_execution_started" } | |
| 84 | + | ||
| 46 | 85 | // EvToolUseStart fires the moment the provider announces a new | |
| 47 | 86 | // tool_use block during streaming, before any arg JSON has | |
| 48 | 87 | // arrived. Gives UIs a hook to pre-render a live "tool is being | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/zut/packages/provider" | 7 | "github.com/bnema/zut/packages/provider" | ||
| 7 | ) | 8 | ) | ||
| @@ -29,6 +30,35 @@ type EvAssistantStart struct{} | |||||
| 29 | 30 | ||||
| 30 | func (EvAssistantStart) Type() string { return "assistant_start" } | 31 | func (EvAssistantStart) Type() string { return "assistant_start" } | ||
| 31 | 32 | ||||
| 33 | + | // RetryScope identifies the retry layer that issued an activity event. | |||
| 34 | + | type RetryScope string | |||
| 35 | + | ||||
| 36 | + | const ( | |||
| 37 | + | RetryScopeProvider RetryScope = "provider" | |||
| 38 | + | RetryScopeAgent RetryScope = "agent" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // EvRequestStarted reports an outbound provider request attempt. | |||
| 42 | + | type EvRequestStarted struct { | |||
| 43 | + | Provider string | |||
| 44 | + | Model string | |||
| 45 | + | Scope RetryScope | |||
| 46 | + | Attempt int | |||
| 47 | + | MaxAttempts int | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (EvRequestStarted) Type() string { return "request_started" } | |||
| 51 | + | ||||
| 52 | + | // EvRetryScheduled reports a retry delay before its upcoming attempt. | |||
| 53 | + | type EvRetryScheduled struct { | |||
| 54 | + | Scope RetryScope | |||
| 55 | + | Attempt int | |||
| 56 | + | MaxAttempts int | |||
| 57 | + | Delay time.Duration | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (EvRetryScheduled) Type() string { return "retry_scheduled" } | |||
| 61 | + | ||||
| 32 | type EvTextDelta struct { | 62 | type EvTextDelta struct { | ||
| 33 | Delta string | 63 | Delta string | ||
| 34 | } | 64 | } | ||
| @@ -43,6 +73,15 @@ type EvToolCall struct { | |||||
| 43 | 73 | ||||
| 44 | func (EvToolCall) Type() string { return "tool_call" } | 74 | func (EvToolCall) Type() string { return "tool_call" } | ||
| 45 | 75 | ||||
| 76 | + | // EvToolExecutionStarted fires after guards and confirmation allow a call, | |||
| 77 | + | // immediately before the tool starts executing. | |||
| 78 | + | type EvToolExecutionStarted struct { | |||
| 79 | + | ID string | |||
| 80 | + | Name string | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (EvToolExecutionStarted) Type() string { return "tool_execution_started" } | |||
| 84 | + | ||||
| 46 | // EvToolUseStart fires the moment the provider announces a new | 85 | // EvToolUseStart fires the moment the provider announces a new | ||
| 47 | // tool_use block during streaming, before any arg JSON has | 86 | // tool_use block during streaming, before any arg JSON has | ||
| 48 | // arrived. Gives UIs a hook to pre-render a live "tool is being | 87 | // arrived. Gives UIs a hook to pre-render a live "tool is being | ||
M packages/core/intercept_test.go +20 -0
| @@ -78,6 +78,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) { | |
| 82 | + | rec := &recordingTool{} | |
| 83 | + | a := NewAgent(nil, "test", "", Registry{"echo": rec}) | |
| 84 | + | ||
| 85 | + | var started []EvToolExecutionStarted | |
| 86 | + | res := a.runOneTool(context.Background(), provider.ToolCallBlock{ | |
| 87 | + | ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`), | |
| 88 | + | }, a.ToolsSnapshot(), func(ev AgentEvent) { | |
| 89 | + | if e, ok := ev.(EvToolExecutionStarted); ok { | |
| 90 | + | started = append(started, e) | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | if res.IsError { | |
| 94 | + | t.Fatalf("runOneTool error = %#v", res) | |
| 95 | + | } | |
| 96 | + | if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" { | |
| 97 | + | t.Fatalf("execution events = %#v", started) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 81 | 101 | // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from | |
| 82 | 102 | // the interceptor returns an error ToolResult with the reason text. | |
| 83 | 103 | func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) { | |
| @@ -78,6 +78,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) { | |||
| 82 | + | rec := &recordingTool{} | |||
| 83 | + | a := NewAgent(nil, "test", "", Registry{"echo": rec}) | |||
| 84 | + | ||||
| 85 | + | var started []EvToolExecutionStarted | |||
| 86 | + | res := a.runOneTool(context.Background(), provider.ToolCallBlock{ | |||
| 87 | + | ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`), | |||
| 88 | + | }, a.ToolsSnapshot(), func(ev AgentEvent) { | |||
| 89 | + | if e, ok := ev.(EvToolExecutionStarted); ok { | |||
| 90 | + | started = append(started, e) | |||
| 91 | + | } | |||
| 92 | + | }) | |||
| 93 | + | if res.IsError { | |||
| 94 | + | t.Fatalf("runOneTool error = %#v", res) | |||
| 95 | + | } | |||
| 96 | + | if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" { | |||
| 97 | + | t.Fatalf("execution events = %#v", started) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 81 | // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from | 101 | // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from | ||
| 82 | // the interceptor returns an error ToolResult with the reason text. | 102 | // the interceptor returns an error ToolResult with the reason text. | ||
| 83 | func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) { | 103 | func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) { | ||
M packages/provider/amazon_bedrock.go +6 -0
| @@ -600,6 +600,12 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||
| 600 | 600 | } else { | |
| 601 | 601 | return nil, fmt.Errorf("bedrock: no auth configured") | |
| 602 | 602 | } | |
| 603 | + | if err := ctx.Err(); err != nil { | |
| 604 | + | return nil, err | |
| 605 | + | } | |
| 606 | + | if req.Lifecycle != nil { | |
| 607 | + | req.Lifecycle.RequestAttempt(1, 1) | |
| 608 | + | } | |
| 603 | 609 | resp, err := c.http.Do(httpReq) | |
| 604 | 610 | if err != nil { | |
| 605 | 611 | return nil, fmt.Errorf("bedrock: %w", err) | |
| @@ -600,6 +600,12 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||||
| 600 | } else { | 600 | } else { | ||
| 601 | return nil, fmt.Errorf("bedrock: no auth configured") | 601 | return nil, fmt.Errorf("bedrock: no auth configured") | ||
| 602 | } | 602 | } | ||
| 603 | + | if err := ctx.Err(); err != nil { | |||
| 604 | + | return nil, err | |||
| 605 | + | } | |||
| 606 | + | if req.Lifecycle != nil { | |||
| 607 | + | req.Lifecycle.RequestAttempt(1, 1) | |||
| 608 | + | } | |||
| 603 | resp, err := c.http.Do(httpReq) | 609 | resp, err := c.http.Do(httpReq) | ||
| 604 | if err != nil { | 610 | if err != nil { | ||
| 605 | return nil, fmt.Errorf("bedrock: %w", err) | 611 | return nil, fmt.Errorf("bedrock: %w", err) | ||
M packages/provider/anthropic.go +1 -1
| @@ -578,7 +578,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||
| 578 | 578 | return httpReq, nil | |
| 579 | 579 | } | |
| 580 | 580 | ||
| 581 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | |
| 581 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 582 | 582 | if err != nil { | |
| 583 | 583 | return nil, fmt.Errorf("anthropic: %w", err) | |
| 584 | 584 | } | |
| @@ -578,7 +578,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event | |||||
| 578 | return httpReq, nil | 578 | return httpReq, nil | ||
| 579 | } | 579 | } | ||
| 580 | 580 | ||||
| 581 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | 581 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) |
| 582 | if err != nil { | 582 | if err != nil { | ||
| 583 | return nil, fmt.Errorf("anthropic: %w", err) | 583 | return nil, fmt.Errorf("anthropic: %w", err) | ||
| 584 | } | 584 | } | ||
A packages/provider/context_overflow.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // IsContextOverflowError reports whether err indicates that the provider | |
| 6 | + | // rejected a request because its payload or tokenized input exceeds the | |
| 7 | + | // serving model's context limit. Provider responses use different HTTP status | |
| 8 | + | // codes, error codes, and prose, so callers use this semantic classification | |
| 9 | + | // when deciding whether compaction is a safe recovery attempt. | |
| 10 | + | func IsContextOverflowError(err error) bool { | |
| 11 | + | if err == nil { | |
| 12 | + | return false | |
| 13 | + | } | |
| 14 | + | msg := strings.ToLower(err.Error()) | |
| 15 | + | return hasHTTPStatus(msg, "413") || | |
| 16 | + | strings.Contains(msg, "payload too large") || | |
| 17 | + | strings.Contains(msg, "request entity too large") || | |
| 18 | + | strings.Contains(msg, "input exceeds the context window") || | |
| 19 | + | strings.Contains(msg, "context window exceeded") || | |
| 20 | + | strings.Contains(msg, "maximum context length") || | |
| 21 | + | strings.Contains(msg, "context_length_exceeded") || | |
| 22 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func hasHTTPStatus(message, code string) bool { | |
| 26 | + | fields := strings.Fields(message) | |
| 27 | + | for i, field := range fields { | |
| 28 | + | if strings.Trim(field, "():,;[]{}") != code { | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | if i == 0 { | |
| 32 | + | return true | |
| 33 | + | } | |
| 34 | + | for offset := 1; offset <= 2 && i-offset >= 0; offset++ { | |
| 35 | + | prefix := strings.Trim(fields[i-offset], "():,;[]{}") | |
| 36 | + | if prefix == "http" || prefix == "status" || prefix == "response" { | |
| 37 | + | return true | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return false | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // IsContextOverflowError reports whether err indicates that the provider | |||
| 6 | + | // rejected a request because its payload or tokenized input exceeds the | |||
| 7 | + | // serving model's context limit. Provider responses use different HTTP status | |||
| 8 | + | // codes, error codes, and prose, so callers use this semantic classification | |||
| 9 | + | // when deciding whether compaction is a safe recovery attempt. | |||
| 10 | + | func IsContextOverflowError(err error) bool { | |||
| 11 | + | if err == nil { | |||
| 12 | + | return false | |||
| 13 | + | } | |||
| 14 | + | msg := strings.ToLower(err.Error()) | |||
| 15 | + | return hasHTTPStatus(msg, "413") || | |||
| 16 | + | strings.Contains(msg, "payload too large") || | |||
| 17 | + | strings.Contains(msg, "request entity too large") || | |||
| 18 | + | strings.Contains(msg, "input exceeds the context window") || | |||
| 19 | + | strings.Contains(msg, "context window exceeded") || | |||
| 20 | + | strings.Contains(msg, "maximum context length") || | |||
| 21 | + | strings.Contains(msg, "context_length_exceeded") || | |||
| 22 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func hasHTTPStatus(message, code string) bool { | |||
| 26 | + | fields := strings.Fields(message) | |||
| 27 | + | for i, field := range fields { | |||
| 28 | + | if strings.Trim(field, "():,;[]{}") != code { | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | if i == 0 { | |||
| 32 | + | return true | |||
| 33 | + | } | |||
| 34 | + | for offset := 1; offset <= 2 && i-offset >= 0; offset++ { | |||
| 35 | + | prefix := strings.Trim(fields[i-offset], "():,;[]{}") | |||
| 36 | + | if prefix == "http" || prefix == "status" || prefix == "response" { | |||
| 37 | + | return true | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | return false | |||
| 42 | + | } | |||
A packages/provider/context_overflow_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestIsContextOverflowError(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | err error | |
| 12 | + | want bool | |
| 13 | + | }{ | |
| 14 | + | {name: "nil error", err: nil, want: false}, | |
| 15 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |
| 16 | + | {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true}, | |
| 17 | + | {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true}, | |
| 18 | + | {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true}, | |
| 19 | + | {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true}, | |
| 20 | + | {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false}, | |
| 21 | + | {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false}, | |
| 22 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 23 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 24 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |
| 25 | + | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |
| 26 | + | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |
| 27 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | if got := IsContextOverflowError(tt.err); got != tt.want { | |
| 33 | + | t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestIsContextOverflowError(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | err error | |||
| 12 | + | want bool | |||
| 13 | + | }{ | |||
| 14 | + | {name: "nil error", err: nil, want: false}, | |||
| 15 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |||
| 16 | + | {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true}, | |||
| 17 | + | {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true}, | |||
| 18 | + | {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true}, | |||
| 19 | + | {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true}, | |||
| 20 | + | {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false}, | |||
| 21 | + | {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false}, | |||
| 22 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |||
| 23 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |||
| 24 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |||
| 25 | + | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |||
| 26 | + | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |||
| 27 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | for _, tt := range tests { | |||
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 32 | + | if got := IsContextOverflowError(tt.err); got != tt.want { | |||
| 33 | + | t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M packages/provider/gemini.go +1 -1
| @@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 501 | 501 | return httpReq, nil | |
| 502 | 502 | } | |
| 503 | 503 | ||
| 504 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | |
| 504 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 505 | 505 | if err != nil { | |
| 506 | 506 | return nil, fmt.Errorf("google: %w", err) | |
| 507 | 507 | } | |
| @@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||||
| 501 | return httpReq, nil | 501 | return httpReq, nil | ||
| 502 | } | 502 | } | ||
| 503 | 503 | ||||
| 504 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | 504 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) |
| 505 | if err != nil { | 505 | if err != nil { | ||
| 506 | return nil, fmt.Errorf("google: %w", err) | 506 | return nil, fmt.Errorf("google: %w", err) | ||
| 507 | } | 507 | } | ||
M packages/provider/openai.go +1 -1
| @@ -504,7 +504,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 504 | 504 | return httpReq, nil | |
| 505 | 505 | } | |
| 506 | 506 | ||
| 507 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | |
| 507 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 508 | 508 | if err != nil { | |
| 509 | 509 | return nil, fmt.Errorf("%s: %w", c.Name(), err) | |
| 510 | 510 | } | |
| @@ -504,7 +504,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||||
| 504 | return httpReq, nil | 504 | return httpReq, nil | ||
| 505 | } | 505 | } | ||
| 506 | 506 | ||||
| 507 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | 507 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) |
| 508 | if err != nil { | 508 | if err != nil { | ||
| 509 | return nil, fmt.Errorf("%s: %w", c.Name(), err) | 509 | return nil, fmt.Errorf("%s: %w", c.Name(), err) | ||
| 510 | } | 510 | } | ||
M packages/provider/openai_codex.go +1 -1
| @@ -418,7 +418,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 418 | 418 | return httpReq, nil | |
| 419 | 419 | } | |
| 420 | 420 | ||
| 421 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | |
| 421 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 422 | 422 | if err != nil { | |
| 423 | 423 | return nil, fmt.Errorf("openai-codex: %w", err) | |
| 424 | 424 | } | |
| @@ -418,7 +418,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 418 | return httpReq, nil | 418 | return httpReq, nil | ||
| 419 | } | 419 | } | ||
| 420 | 420 | ||||
| 421 | - | resp, err := doStreamWithRetry(ctx, c.http, newReq) | 421 | + | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) |
| 422 | if err != nil { | 422 | if err != nil { | ||
| 423 | return nil, fmt.Errorf("openai-codex: %w", err) | 423 | return nil, fmt.Errorf("openai-codex: %w", err) | ||
| 424 | } | 424 | } | ||
M packages/provider/provider.go +14 -0
| @@ -51,6 +51,9 @@ type ToolCallBlock struct { | |||
| 51 | 51 | Name string `json:"name"` | |
| 52 | 52 | Arguments json.RawMessage `json:"arguments"` | |
| 53 | 53 | ThoughtSignature string `json:"thought_signature,omitempty"` | |
| 54 | + | // Origin is host-only routing metadata. It is never serialized to a | |
| 55 | + | // provider or stored in the transcript. | |
| 56 | + | Origin string `json:"-"` | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | func (ToolCallBlock) isContent() {} | |
| @@ -245,6 +248,14 @@ type EventDone struct { | |||
| 245 | 248 | ||
| 246 | 249 | func (EventDone) isEvent() {} | |
| 247 | 250 | ||
| 251 | + | // RequestLifecycle observes request attempts and retry delays without being | |
| 252 | + | // serialized onto a provider wire. Implementations must call RequestAttempt | |
| 253 | + | // immediately before an outbound attempt and RetryScheduled before sleeping. | |
| 254 | + | type RequestLifecycle interface { | |
| 255 | + | RequestAttempt(attempt, maxAttempts int) | |
| 256 | + | RetryScheduled(attempt, maxAttempts int, delay time.Duration) | |
| 257 | + | } | |
| 258 | + | ||
| 248 | 259 | // Request is a single LLM call. | |
| 249 | 260 | type Request struct { | |
| 250 | 261 | Model string | |
| @@ -260,6 +271,9 @@ type Request struct { | |||
| 260 | 271 | // FastMode requests OpenAI's fast service tier. Providers that do not | |
| 261 | 272 | // implement the OpenAI service-tier contract reject the request. | |
| 262 | 273 | FastMode bool | |
| 274 | + | // Lifecycle receives in-memory request-attempt notifications. It is not | |
| 275 | + | // included in any provider request payload. | |
| 276 | + | Lifecycle RequestLifecycle | |
| 263 | 277 | } | |
| 264 | 278 | ||
| 265 | 279 | // OpenAI's current Fast mode is represented as the priority service tier on | |
| @@ -51,6 +51,9 @@ type ToolCallBlock struct { | |||||
| 51 | Name string `json:"name"` | 51 | Name string `json:"name"` | ||
| 52 | Arguments json.RawMessage `json:"arguments"` | 52 | Arguments json.RawMessage `json:"arguments"` | ||
| 53 | ThoughtSignature string `json:"thought_signature,omitempty"` | 53 | ThoughtSignature string `json:"thought_signature,omitempty"` | ||
| 54 | + | // Origin is host-only routing metadata. It is never serialized to a | |||
| 55 | + | // provider or stored in the transcript. | |||
| 56 | + | Origin string `json:"-"` | |||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | func (ToolCallBlock) isContent() {} | 59 | func (ToolCallBlock) isContent() {} | ||
| @@ -245,6 +248,14 @@ type EventDone struct { | |||||
| 245 | 248 | ||||
| 246 | func (EventDone) isEvent() {} | 249 | func (EventDone) isEvent() {} | ||
| 247 | 250 | ||||
| 251 | + | // RequestLifecycle observes request attempts and retry delays without being | |||
| 252 | + | // serialized onto a provider wire. Implementations must call RequestAttempt | |||
| 253 | + | // immediately before an outbound attempt and RetryScheduled before sleeping. | |||
| 254 | + | type RequestLifecycle interface { | |||
| 255 | + | RequestAttempt(attempt, maxAttempts int) | |||
| 256 | + | RetryScheduled(attempt, maxAttempts int, delay time.Duration) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 248 | // Request is a single LLM call. | 259 | // Request is a single LLM call. | ||
| 249 | type Request struct { | 260 | type Request struct { | ||
| 250 | Model string | 261 | Model string | ||
| @@ -260,6 +271,9 @@ type Request struct { | |||||
| 260 | // FastMode requests OpenAI's fast service tier. Providers that do not | 271 | // FastMode requests OpenAI's fast service tier. Providers that do not | ||
| 261 | // implement the OpenAI service-tier contract reject the request. | 272 | // implement the OpenAI service-tier contract reject the request. | ||
| 262 | FastMode bool | 273 | FastMode bool | ||
| 274 | + | // Lifecycle receives in-memory request-attempt notifications. It is not | |||
| 275 | + | // included in any provider request payload. | |||
| 276 | + | Lifecycle RequestLifecycle | |||
| 263 | } | 277 | } | ||
| 264 | 278 | ||||
| 265 | // OpenAI's current Fast mode is represented as the priority service tier on | 279 | // OpenAI's current Fast mode is represented as the priority service tier on | ||
M packages/provider/retry.go +17 -1
| @@ -63,9 +63,10 @@ func streamRetryBackoff(attempt int) time.Duration { | |||
| 63 | 63 | // If every attempt fails, the last error/response is returned so the | |
| 64 | 64 | // caller can format a normal error message. On context cancellation | |
| 65 | 65 | // the loop bails out immediately with ctx.Err(). | |
| 66 | - | func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) { | |
| 66 | + | func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error), lifecycle RequestLifecycle) (*http.Response, error) { | |
| 67 | 67 | var lastErr error | |
| 68 | 68 | serverDelay := time.Duration(-1) // <0: use the default fixed backoff | |
| 69 | + | maxAttempts := streamRetryAttempts + 1 | |
| 69 | 70 | for attempt := 0; attempt <= streamRetryAttempts; attempt++ { | |
| 70 | 71 | if attempt > 0 { | |
| 71 | 72 | delay := streamRetryBackoff(attempt) | |
| @@ -73,16 +74,31 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() ( | |||
| 73 | 74 | delay = serverDelay | |
| 74 | 75 | } | |
| 75 | 76 | serverDelay = -1 | |
| 77 | + | if err := ctx.Err(); err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | if lifecycle != nil { | |
| 81 | + | lifecycle.RetryScheduled(attempt+1, maxAttempts, delay) | |
| 82 | + | } | |
| 76 | 83 | select { | |
| 77 | 84 | case <-ctx.Done(): | |
| 78 | 85 | return nil, ctx.Err() | |
| 79 | 86 | case <-time.After(delay): | |
| 80 | 87 | } | |
| 81 | 88 | } | |
| 89 | + | if err := ctx.Err(); err != nil { | |
| 90 | + | return nil, err | |
| 91 | + | } | |
| 82 | 92 | req, err := newReq() | |
| 83 | 93 | if err != nil { | |
| 84 | 94 | return nil, err | |
| 85 | 95 | } | |
| 96 | + | if err := ctx.Err(); err != nil { | |
| 97 | + | return nil, err | |
| 98 | + | } | |
| 99 | + | if lifecycle != nil { | |
| 100 | + | lifecycle.RequestAttempt(attempt+1, maxAttempts) | |
| 101 | + | } | |
| 86 | 102 | resp, err := client.Do(req) | |
| 87 | 103 | if err != nil { | |
| 88 | 104 | lastErr = err | |
| @@ -63,9 +63,10 @@ func streamRetryBackoff(attempt int) time.Duration { | |||||
| 63 | // If every attempt fails, the last error/response is returned so the | 63 | // If every attempt fails, the last error/response is returned so the | ||
| 64 | // caller can format a normal error message. On context cancellation | 64 | // caller can format a normal error message. On context cancellation | ||
| 65 | // the loop bails out immediately with ctx.Err(). | 65 | // the loop bails out immediately with ctx.Err(). | ||
| 66 | - | func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) { | 66 | + | func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error), lifecycle RequestLifecycle) (*http.Response, error) { |
| 67 | var lastErr error | 67 | var lastErr error | ||
| 68 | serverDelay := time.Duration(-1) // <0: use the default fixed backoff | 68 | serverDelay := time.Duration(-1) // <0: use the default fixed backoff | ||
| 69 | + | maxAttempts := streamRetryAttempts + 1 | |||
| 69 | for attempt := 0; attempt <= streamRetryAttempts; attempt++ { | 70 | for attempt := 0; attempt <= streamRetryAttempts; attempt++ { | ||
| 70 | if attempt > 0 { | 71 | if attempt > 0 { | ||
| 71 | delay := streamRetryBackoff(attempt) | 72 | delay := streamRetryBackoff(attempt) | ||
| @@ -73,16 +74,31 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() ( | |||||
| 73 | delay = serverDelay | 74 | delay = serverDelay | ||
| 74 | } | 75 | } | ||
| 75 | serverDelay = -1 | 76 | serverDelay = -1 | ||
| 77 | + | if err := ctx.Err(); err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | if lifecycle != nil { | |||
| 81 | + | lifecycle.RetryScheduled(attempt+1, maxAttempts, delay) | |||
| 82 | + | } | |||
| 76 | select { | 83 | select { | ||
| 77 | case <-ctx.Done(): | 84 | case <-ctx.Done(): | ||
| 78 | return nil, ctx.Err() | 85 | return nil, ctx.Err() | ||
| 79 | case <-time.After(delay): | 86 | case <-time.After(delay): | ||
| 80 | } | 87 | } | ||
| 81 | } | 88 | } | ||
| 89 | + | if err := ctx.Err(); err != nil { | |||
| 90 | + | return nil, err | |||
| 91 | + | } | |||
| 82 | req, err := newReq() | 92 | req, err := newReq() | ||
| 83 | if err != nil { | 93 | if err != nil { | ||
| 84 | return nil, err | 94 | return nil, err | ||
| 85 | } | 95 | } | ||
| 96 | + | if err := ctx.Err(); err != nil { | |||
| 97 | + | return nil, err | |||
| 98 | + | } | |||
| 99 | + | if lifecycle != nil { | |||
| 100 | + | lifecycle.RequestAttempt(attempt+1, maxAttempts) | |||
| 101 | + | } | |||
| 86 | resp, err := client.Do(req) | 102 | resp, err := client.Do(req) | ||
| 87 | if err != nil { | 103 | if err != nil { | ||
| 88 | lastErr = err | 104 | lastErr = err | ||
M packages/provider/retry_test.go +119 -1
| @@ -2,6 +2,7 @@ package provider | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "strings" | |
| @@ -38,7 +39,8 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) { | |||
| 38 | 39 | defer srv.Close() | |
| 39 | 40 | ||
| 40 | 41 | c := NewOpenAI("x", srv.URL) | |
| 41 | - | evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"}) | |
| 42 | + | lifecycle := &recordingRequestLifecycle{} | |
| 43 | + | evs, err := c.Stream(context.Background(), Request{Model: "gpt-5", Lifecycle: lifecycle}) | |
| 42 | 44 | if err != nil { | |
| 43 | 45 | t.Fatalf("stream returned error: %v", err) | |
| 44 | 46 | } | |
| @@ -54,6 +56,12 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) { | |||
| 54 | 56 | if got := attempts.Load(); got != 3 { | |
| 55 | 57 | t.Fatalf("attempts=%d want 3", got) | |
| 56 | 58 | } | |
| 59 | + | if got := lifecycle.attempts; len(got) != 3 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[2] != (lifecycleAttempt{attempt: 3, max: 3}) { | |
| 60 | + | t.Fatalf("lifecycle attempts = %#v, want 1 through 3", got) | |
| 61 | + | } | |
| 62 | + | if got := lifecycle.retries; len(got) != 2 || got[0].attempt != 2 || got[1].attempt != 3 { | |
| 63 | + | t.Fatalf("lifecycle retries = %#v, want retries before attempts 2 and 3", got) | |
| 64 | + | } | |
| 57 | 65 | } | |
| 58 | 66 | ||
| 59 | 67 | // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when | |
| @@ -208,6 +216,74 @@ func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) { | |||
| 208 | 216 | } | |
| 209 | 217 | } | |
| 210 | 218 | ||
| 219 | + | type lifecycleAttempt struct { | |
| 220 | + | attempt int | |
| 221 | + | max int | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | type lifecycleRetry struct { | |
| 225 | + | attempt int | |
| 226 | + | max int | |
| 227 | + | delay time.Duration | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | type recordingRequestLifecycle struct { | |
| 231 | + | attempts []lifecycleAttempt | |
| 232 | + | retries []lifecycleRetry | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (r *recordingRequestLifecycle) RequestAttempt(attempt, maxAttempts int) { | |
| 236 | + | r.attempts = append(r.attempts, lifecycleAttempt{attempt: attempt, max: maxAttempts}) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func (r *recordingRequestLifecycle) RetryScheduled(attempt, maxAttempts int, delay time.Duration) { | |
| 240 | + | r.retries = append(r.retries, lifecycleRetry{attempt: attempt, max: maxAttempts, delay: delay}) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestDoStreamWithRetryReportsLifecycle(t *testing.T) { | |
| 244 | + | var attempts atomic.Int32 | |
| 245 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 246 | + | if attempts.Add(1) == 1 { | |
| 247 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | w.WriteHeader(http.StatusOK) | |
| 251 | + | })) | |
| 252 | + | defer srv.Close() | |
| 253 | + | ||
| 254 | + | lifecycle := &recordingRequestLifecycle{} | |
| 255 | + | resp, err := doStreamWithRetry(context.Background(), srv.Client(), func() (*http.Request, error) { | |
| 256 | + | return http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, nil) | |
| 257 | + | }, lifecycle) | |
| 258 | + | if err != nil { | |
| 259 | + | t.Fatalf("doStreamWithRetry: %v", err) | |
| 260 | + | } | |
| 261 | + | resp.Body.Close() | |
| 262 | + | ||
| 263 | + | if got := lifecycle.attempts; len(got) != 2 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[1] != (lifecycleAttempt{attempt: 2, max: 3}) { | |
| 264 | + | t.Fatalf("attempts = %#v, want attempts 1 and 2 of 3", got) | |
| 265 | + | } | |
| 266 | + | if got := lifecycle.retries; len(got) != 1 || got[0].attempt != 2 || got[0].max != 3 || got[0].delay != 250*time.Millisecond { | |
| 267 | + | t.Fatalf("retries = %#v, want attempt 2 of 3 after 250ms", got) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestDoStreamWithRetryStopsLifecycleAfterCancellation(t *testing.T) { | |
| 272 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 273 | + | cancel() | |
| 274 | + | lifecycle := &recordingRequestLifecycle{} | |
| 275 | + | _, err := doStreamWithRetry(ctx, http.DefaultClient, func() (*http.Request, error) { | |
| 276 | + | t.Fatal("new request called after cancellation") | |
| 277 | + | return nil, nil | |
| 278 | + | }, lifecycle) | |
| 279 | + | if !errors.Is(err, context.Canceled) { | |
| 280 | + | t.Fatalf("error = %v, want context.Canceled", err) | |
| 281 | + | } | |
| 282 | + | if len(lifecycle.attempts) != 0 || len(lifecycle.retries) != 0 { | |
| 283 | + | t.Fatalf("lifecycle after cancellation = %#v %#v", lifecycle.attempts, lifecycle.retries) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 211 | 287 | func TestRetryAfterDelay(t *testing.T) { | |
| 212 | 288 | mk := func(kv ...string) http.Header { | |
| 213 | 289 | h := http.Header{} | |
| @@ -291,3 +367,45 @@ func TestIsTransientConnectError(t *testing.T) { | |||
| 291 | 367 | type stringErr string | |
| 292 | 368 | ||
| 293 | 369 | func (s stringErr) Error() string { return string(s) } | |
| 370 | + | ||
| 371 | + | func TestDoStreamWithRetryDoesNotReportAttemptWhenRequestCreationFails(t *testing.T) { | |
| 372 | + | lifecycle := &recordingRequestLifecycle{} | |
| 373 | + | _, err := doStreamWithRetry(context.Background(), http.DefaultClient, func() (*http.Request, error) { | |
| 374 | + | return nil, errors.New("cannot build request") | |
| 375 | + | }, lifecycle) | |
| 376 | + | if err == nil || !strings.Contains(err.Error(), "cannot build request") { | |
| 377 | + | t.Fatalf("error = %v, want request creation failure", err) | |
| 378 | + | } | |
| 379 | + | if len(lifecycle.attempts) != 0 { | |
| 380 | + | t.Fatalf("request attempts = %#v, want none", lifecycle.attempts) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | type retryRoundTripper func(*http.Request) (*http.Response, error) | |
| 385 | + | ||
| 386 | + | func (f retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 387 | + | return f(req) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestDoStreamWithRetryDoesNotReportCancelledRetry(t *testing.T) { | |
| 391 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 392 | + | defer cancel() | |
| 393 | + | client := &http.Client{Transport: retryRoundTripper(func(_ *http.Request) (*http.Response, error) { | |
| 394 | + | cancel() | |
| 395 | + | return &http.Response{ | |
| 396 | + | StatusCode: http.StatusServiceUnavailable, | |
| 397 | + | Header: make(http.Header), | |
| 398 | + | Body: http.NoBody, | |
| 399 | + | }, nil | |
| 400 | + | })} | |
| 401 | + | lifecycle := &recordingRequestLifecycle{} | |
| 402 | + | _, err := doStreamWithRetry(ctx, client, func() (*http.Request, error) { | |
| 403 | + | return http.NewRequestWithContext(ctx, http.MethodPost, "https://example.test", nil) | |
| 404 | + | }, lifecycle) | |
| 405 | + | if !errors.Is(err, context.Canceled) { | |
| 406 | + | t.Fatalf("error = %v, want context.Canceled", err) | |
| 407 | + | } | |
| 408 | + | if len(lifecycle.retries) != 0 { | |
| 409 | + | t.Fatalf("retry lifecycle = %#v, want none after cancellation", lifecycle.retries) | |
| 410 | + | } | |
| 411 | + | } | |
| @@ -2,6 +2,7 @@ package provider | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 7 | "strings" | 8 | "strings" | ||
| @@ -38,7 +39,8 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) { | |||||
| 38 | defer srv.Close() | 39 | defer srv.Close() | ||
| 39 | 40 | ||||
| 40 | c := NewOpenAI("x", srv.URL) | 41 | c := NewOpenAI("x", srv.URL) | ||
| 41 | - | evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"}) | 42 | + | lifecycle := &recordingRequestLifecycle{} |
| 43 | + | evs, err := c.Stream(context.Background(), Request{Model: "gpt-5", Lifecycle: lifecycle}) | |||
| 42 | if err != nil { | 44 | if err != nil { | ||
| 43 | t.Fatalf("stream returned error: %v", err) | 45 | t.Fatalf("stream returned error: %v", err) | ||
| 44 | } | 46 | } | ||
| @@ -54,6 +56,12 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) { | |||||
| 54 | if got := attempts.Load(); got != 3 { | 56 | if got := attempts.Load(); got != 3 { | ||
| 55 | t.Fatalf("attempts=%d want 3", got) | 57 | t.Fatalf("attempts=%d want 3", got) | ||
| 56 | } | 58 | } | ||
| 59 | + | if got := lifecycle.attempts; len(got) != 3 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[2] != (lifecycleAttempt{attempt: 3, max: 3}) { | |||
| 60 | + | t.Fatalf("lifecycle attempts = %#v, want 1 through 3", got) | |||
| 61 | + | } | |||
| 62 | + | if got := lifecycle.retries; len(got) != 2 || got[0].attempt != 2 || got[1].attempt != 3 { | |||
| 63 | + | t.Fatalf("lifecycle retries = %#v, want retries before attempts 2 and 3", got) | |||
| 64 | + | } | |||
| 57 | } | 65 | } | ||
| 58 | 66 | ||||
| 59 | // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when | 67 | // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when | ||
| @@ -208,6 +216,74 @@ func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) { | |||||
| 208 | } | 216 | } | ||
| 209 | } | 217 | } | ||
| 210 | 218 | ||||
| 219 | + | type lifecycleAttempt struct { | |||
| 220 | + | attempt int | |||
| 221 | + | max int | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | type lifecycleRetry struct { | |||
| 225 | + | attempt int | |||
| 226 | + | max int | |||
| 227 | + | delay time.Duration | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | type recordingRequestLifecycle struct { | |||
| 231 | + | attempts []lifecycleAttempt | |||
| 232 | + | retries []lifecycleRetry | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (r *recordingRequestLifecycle) RequestAttempt(attempt, maxAttempts int) { | |||
| 236 | + | r.attempts = append(r.attempts, lifecycleAttempt{attempt: attempt, max: maxAttempts}) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func (r *recordingRequestLifecycle) RetryScheduled(attempt, maxAttempts int, delay time.Duration) { | |||
| 240 | + | r.retries = append(r.retries, lifecycleRetry{attempt: attempt, max: maxAttempts, delay: delay}) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestDoStreamWithRetryReportsLifecycle(t *testing.T) { | |||
| 244 | + | var attempts atomic.Int32 | |||
| 245 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 246 | + | if attempts.Add(1) == 1 { | |||
| 247 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | w.WriteHeader(http.StatusOK) | |||
| 251 | + | })) | |||
| 252 | + | defer srv.Close() | |||
| 253 | + | ||||
| 254 | + | lifecycle := &recordingRequestLifecycle{} | |||
| 255 | + | resp, err := doStreamWithRetry(context.Background(), srv.Client(), func() (*http.Request, error) { | |||
| 256 | + | return http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, nil) | |||
| 257 | + | }, lifecycle) | |||
| 258 | + | if err != nil { | |||
| 259 | + | t.Fatalf("doStreamWithRetry: %v", err) | |||
| 260 | + | } | |||
| 261 | + | resp.Body.Close() | |||
| 262 | + | ||||
| 263 | + | if got := lifecycle.attempts; len(got) != 2 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[1] != (lifecycleAttempt{attempt: 2, max: 3}) { | |||
| 264 | + | t.Fatalf("attempts = %#v, want attempts 1 and 2 of 3", got) | |||
| 265 | + | } | |||
| 266 | + | if got := lifecycle.retries; len(got) != 1 || got[0].attempt != 2 || got[0].max != 3 || got[0].delay != 250*time.Millisecond { | |||
| 267 | + | t.Fatalf("retries = %#v, want attempt 2 of 3 after 250ms", got) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestDoStreamWithRetryStopsLifecycleAfterCancellation(t *testing.T) { | |||
| 272 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 273 | + | cancel() | |||
| 274 | + | lifecycle := &recordingRequestLifecycle{} | |||
| 275 | + | _, err := doStreamWithRetry(ctx, http.DefaultClient, func() (*http.Request, error) { | |||
| 276 | + | t.Fatal("new request called after cancellation") | |||
| 277 | + | return nil, nil | |||
| 278 | + | }, lifecycle) | |||
| 279 | + | if !errors.Is(err, context.Canceled) { | |||
| 280 | + | t.Fatalf("error = %v, want context.Canceled", err) | |||
| 281 | + | } | |||
| 282 | + | if len(lifecycle.attempts) != 0 || len(lifecycle.retries) != 0 { | |||
| 283 | + | t.Fatalf("lifecycle after cancellation = %#v %#v", lifecycle.attempts, lifecycle.retries) | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 211 | func TestRetryAfterDelay(t *testing.T) { | 287 | func TestRetryAfterDelay(t *testing.T) { | ||
| 212 | mk := func(kv ...string) http.Header { | 288 | mk := func(kv ...string) http.Header { | ||
| 213 | h := http.Header{} | 289 | h := http.Header{} | ||
| @@ -291,3 +367,45 @@ func TestIsTransientConnectError(t *testing.T) { | |||||
| 291 | type stringErr string | 367 | type stringErr string | ||
| 292 | 368 | ||||
| 293 | func (s stringErr) Error() string { return string(s) } | 369 | func (s stringErr) Error() string { return string(s) } | ||
| 370 | + | ||||
| 371 | + | func TestDoStreamWithRetryDoesNotReportAttemptWhenRequestCreationFails(t *testing.T) { | |||
| 372 | + | lifecycle := &recordingRequestLifecycle{} | |||
| 373 | + | _, err := doStreamWithRetry(context.Background(), http.DefaultClient, func() (*http.Request, error) { | |||
| 374 | + | return nil, errors.New("cannot build request") | |||
| 375 | + | }, lifecycle) | |||
| 376 | + | if err == nil || !strings.Contains(err.Error(), "cannot build request") { | |||
| 377 | + | t.Fatalf("error = %v, want request creation failure", err) | |||
| 378 | + | } | |||
| 379 | + | if len(lifecycle.attempts) != 0 { | |||
| 380 | + | t.Fatalf("request attempts = %#v, want none", lifecycle.attempts) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | type retryRoundTripper func(*http.Request) (*http.Response, error) | |||
| 385 | + | ||||
| 386 | + | func (f retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 387 | + | return f(req) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestDoStreamWithRetryDoesNotReportCancelledRetry(t *testing.T) { | |||
| 391 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 392 | + | defer cancel() | |||
| 393 | + | client := &http.Client{Transport: retryRoundTripper(func(_ *http.Request) (*http.Response, error) { | |||
| 394 | + | cancel() | |||
| 395 | + | return &http.Response{ | |||
| 396 | + | StatusCode: http.StatusServiceUnavailable, | |||
| 397 | + | Header: make(http.Header), | |||
| 398 | + | Body: http.NoBody, | |||
| 399 | + | }, nil | |||
| 400 | + | })} | |||
| 401 | + | lifecycle := &recordingRequestLifecycle{} | |||
| 402 | + | _, err := doStreamWithRetry(ctx, client, func() (*http.Request, error) { | |||
| 403 | + | return http.NewRequestWithContext(ctx, http.MethodPost, "https://example.test", nil) | |||
| 404 | + | }, lifecycle) | |||
| 405 | + | if !errors.Is(err, context.Canceled) { | |||
| 406 | + | t.Fatalf("error = %v, want context.Canceled", err) | |||
| 407 | + | } | |||
| 408 | + | if len(lifecycle.retries) != 0 { | |||
| 409 | + | t.Fatalf("retry lifecycle = %#v, want none after cancellation", lifecycle.retries) | |||
| 410 | + | } | |||
| 411 | + | } | |||
M packages/tui/theme.go +1 -32
| @@ -73,7 +73,7 @@ type Theme struct { | |||
| 73 | 73 | ToolOut TerminalColor | |
| 74 | 74 | Error TerminalColor | |
| 75 | 75 | Warning TerminalColor | |
| 76 | - | Spinner TerminalColor // spinner + funny working line | |
| 76 | + | Spinner TerminalColor // spinner frame and activity label | |
| 77 | 77 | ThinkingMax TerminalColor // status color for the opt-in max reasoning level | |
| 78 | 78 | SelectionBG TerminalColor // background for highlighted rows | |
| 79 | 79 | SelectionFG TerminalColor // foreground for highlighted rows | |
| @@ -86,7 +86,6 @@ type Theme struct { | |||
| 86 | 86 | Terminal TerminalProfile | |
| 87 | 87 | ||
| 88 | 88 | SpinnerFrames []string | |
| 89 | - | SpinnerMessages []string | |
| 90 | 89 | SpinnerIntervalMS int | |
| 91 | 90 | ||
| 92 | 91 | SyntaxBaseStyle string | |
| @@ -151,34 +150,6 @@ var defaultSpinnerFrames = []string{ | |||
| 151 | 150 | "⠓", | |
| 152 | 151 | } | |
| 153 | 152 | ||
| 154 | - | var defaultSpinnerMessages = []string{ | |
| 155 | - | "thinking", | |
| 156 | - | "reticulating splines", | |
| 157 | - | "bribing the tokenizer", | |
| 158 | - | "asking the rubber duck", | |
| 159 | - | "summoning daemons", | |
| 160 | - | "consulting the oracle", | |
| 161 | - | "herding tokens", | |
| 162 | - | "compiling excuses", | |
| 163 | - | "poking the model", | |
| 164 | - | "negotiating with rate limits", | |
| 165 | - | "picking a fight with syntax", | |
| 166 | - | "reading between the bits", | |
| 167 | - | "tasting the semicolons", | |
| 168 | - | "pretending to understand the code", | |
| 169 | - | "petting the cache", | |
| 170 | - | "drafting clever replies", | |
| 171 | - | "warming up the GPU choir", | |
| 172 | - | "arguing with a stack trace", | |
| 173 | - | "googling the answer (not really)", | |
| 174 | - | "rewriting history", | |
| 175 | - | "every draft is a stone in the work", | |
| 176 | - | "bringing order to the unhewn", | |
| 177 | - | "finding the load-bearing measure", | |
| 178 | - | "where clarity grows, work grows lighter", | |
| 179 | - | "every correction serves the work", | |
| 180 | - | } | |
| 181 | - | ||
| 182 | 153 | var Dark = Theme{ | |
| 183 | 154 | FG: Color256(253), | |
| 184 | 155 | Muted: Color256(244), | |
| @@ -196,7 +167,6 @@ var Dark = Theme{ | |||
| 196 | 167 | SelectionBG: Color256(24), // deep blue background | |
| 197 | 168 | SelectionFG: Color256(231), // near-white foreground | |
| 198 | 169 | SpinnerFrames: defaultSpinnerFrames, | |
| 199 | - | SpinnerMessages: defaultSpinnerMessages, | |
| 200 | 170 | SpinnerIntervalMS: 80, | |
| 201 | 171 | SyntaxBaseStyle: "monokai", | |
| 202 | 172 | Syntax: nordSyntax, | |
| @@ -219,7 +189,6 @@ var Light = Theme{ | |||
| 219 | 189 | SelectionBG: Color256(153), // light blue | |
| 220 | 190 | SelectionFG: Color256(232), // near-black | |
| 221 | 191 | SpinnerFrames: defaultSpinnerFrames, | |
| 222 | - | SpinnerMessages: defaultSpinnerMessages, | |
| 223 | 192 | SpinnerIntervalMS: 80, | |
| 224 | 193 | SyntaxBaseStyle: "monokai", | |
| 225 | 194 | Syntax: nordSyntax, | |
| @@ -73,7 +73,7 @@ type Theme struct { | |||||
| 73 | ToolOut TerminalColor | 73 | ToolOut TerminalColor | ||
| 74 | Error TerminalColor | 74 | Error TerminalColor | ||
| 75 | Warning TerminalColor | 75 | Warning TerminalColor | ||
| 76 | - | Spinner TerminalColor // spinner + funny working line | 76 | + | Spinner TerminalColor // spinner frame and activity label |
| 77 | ThinkingMax TerminalColor // status color for the opt-in max reasoning level | 77 | ThinkingMax TerminalColor // status color for the opt-in max reasoning level | ||
| 78 | SelectionBG TerminalColor // background for highlighted rows | 78 | SelectionBG TerminalColor // background for highlighted rows | ||
| 79 | SelectionFG TerminalColor // foreground for highlighted rows | 79 | SelectionFG TerminalColor // foreground for highlighted rows | ||
| @@ -86,7 +86,6 @@ type Theme struct { | |||||
| 86 | Terminal TerminalProfile | 86 | Terminal TerminalProfile | ||
| 87 | 87 | ||||
| 88 | SpinnerFrames []string | 88 | SpinnerFrames []string | ||
| 89 | - | SpinnerMessages []string | |||
| 90 | SpinnerIntervalMS int | 89 | SpinnerIntervalMS int | ||
| 91 | 90 | ||||
| 92 | SyntaxBaseStyle string | 91 | SyntaxBaseStyle string | ||
| @@ -151,34 +150,6 @@ var defaultSpinnerFrames = []string{ | |||||
| 151 | "⠓", | 150 | "⠓", | ||
| 152 | } | 151 | } | ||
| 153 | 152 | ||||
| 154 | - | var defaultSpinnerMessages = []string{ | |||
| 155 | - | "thinking", | |||
| 156 | - | "reticulating splines", | |||
| 157 | - | "bribing the tokenizer", | |||
| 158 | - | "asking the rubber duck", | |||
| 159 | - | "summoning daemons", | |||
| 160 | - | "consulting the oracle", | |||
| 161 | - | "herding tokens", | |||
| 162 | - | "compiling excuses", | |||
| 163 | - | "poking the model", | |||
| 164 | - | "negotiating with rate limits", | |||
| 165 | - | "picking a fight with syntax", | |||
| 166 | - | "reading between the bits", | |||
| 167 | - | "tasting the semicolons", | |||
| 168 | - | "pretending to understand the code", | |||
| 169 | - | "petting the cache", | |||
| 170 | - | "drafting clever replies", | |||
| 171 | - | "warming up the GPU choir", | |||
| 172 | - | "arguing with a stack trace", | |||
| 173 | - | "googling the answer (not really)", | |||
| 174 | - | "rewriting history", | |||
| 175 | - | "every draft is a stone in the work", | |||
| 176 | - | "bringing order to the unhewn", | |||
| 177 | - | "finding the load-bearing measure", | |||
| 178 | - | "where clarity grows, work grows lighter", | |||
| 179 | - | "every correction serves the work", | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | var Dark = Theme{ | 153 | var Dark = Theme{ | ||
| 183 | FG: Color256(253), | 154 | FG: Color256(253), | ||
| 184 | Muted: Color256(244), | 155 | Muted: Color256(244), | ||
| @@ -196,7 +167,6 @@ var Dark = Theme{ | |||||
| 196 | SelectionBG: Color256(24), // deep blue background | 167 | SelectionBG: Color256(24), // deep blue background | ||
| 197 | SelectionFG: Color256(231), // near-white foreground | 168 | SelectionFG: Color256(231), // near-white foreground | ||
| 198 | SpinnerFrames: defaultSpinnerFrames, | 169 | SpinnerFrames: defaultSpinnerFrames, | ||
| 199 | - | SpinnerMessages: defaultSpinnerMessages, | |||
| 200 | SpinnerIntervalMS: 80, | 170 | SpinnerIntervalMS: 80, | ||
| 201 | SyntaxBaseStyle: "monokai", | 171 | SyntaxBaseStyle: "monokai", | ||
| 202 | Syntax: nordSyntax, | 172 | Syntax: nordSyntax, | ||
| @@ -219,7 +189,6 @@ var Light = Theme{ | |||||
| 219 | SelectionBG: Color256(153), // light blue | 189 | SelectionBG: Color256(153), // light blue | ||
| 220 | SelectionFG: Color256(232), // near-black | 190 | SelectionFG: Color256(232), // near-black | ||
| 221 | SpinnerFrames: defaultSpinnerFrames, | 191 | SpinnerFrames: defaultSpinnerFrames, | ||
| 222 | - | SpinnerMessages: defaultSpinnerMessages, | |||
| 223 | SpinnerIntervalMS: 80, | 192 | SpinnerIntervalMS: 80, | ||
| 224 | SyntaxBaseStyle: "monokai", | 193 | SyntaxBaseStyle: "monokai", | ||
| 225 | Syntax: nordSyntax, | 194 | Syntax: nordSyntax, | ||
M packages/tui/theme_loader.go +1 -5
| @@ -38,7 +38,7 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error { | |||
| 38 | 38 | return err | |
| 39 | 39 | } | |
| 40 | 40 | // Allow a tiny theme file with overrides at the top level, e.g. | |
| 41 | - | // {"spinner_frames":[".","o"],"spinner_messages":["working"]}. | |
| 41 | + | // {"spinner_frames":[".","o"],"spinner_interval_ms":120}. | |
| 42 | 42 | // Metadata fields are ignored by ThemeOverrides because they do not | |
| 43 | 43 | // have matching json tags. | |
| 44 | 44 | _ = json.Unmarshal(data, &a.Overrides) | |
| @@ -97,7 +97,6 @@ type ThemeOverrides struct { | |||
| 97 | 97 | SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"` | |
| 98 | 98 | SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"` | |
| 99 | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | |
| 100 | - | SpinnerMessages []string `json:"spinner_messages,omitempty"` | |
| 101 | 100 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | |
| 102 | 101 | SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"` | |
| 103 | 102 | Syntax SyntaxThemeOverrides `json:"syntax,omitempty"` | |
| @@ -502,9 +501,6 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||
| 502 | 501 | if len(o.SpinnerFrames) > 0 { | |
| 503 | 502 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | |
| 504 | 503 | } | |
| 505 | - | if len(o.SpinnerMessages) > 0 { | |
| 506 | - | th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...) | |
| 507 | - | } | |
| 508 | 504 | if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 { | |
| 509 | 505 | th.SpinnerIntervalMS = *o.SpinnerIntervalMS | |
| 510 | 506 | } | |
| @@ -38,7 +38,7 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error { | |||||
| 38 | return err | 38 | return err | ||
| 39 | } | 39 | } | ||
| 40 | // Allow a tiny theme file with overrides at the top level, e.g. | 40 | // Allow a tiny theme file with overrides at the top level, e.g. | ||
| 41 | - | // {"spinner_frames":[".","o"],"spinner_messages":["working"]}. | 41 | + | // {"spinner_frames":[".","o"],"spinner_interval_ms":120}. |
| 42 | // Metadata fields are ignored by ThemeOverrides because they do not | 42 | // Metadata fields are ignored by ThemeOverrides because they do not | ||
| 43 | // have matching json tags. | 43 | // have matching json tags. | ||
| 44 | _ = json.Unmarshal(data, &a.Overrides) | 44 | _ = json.Unmarshal(data, &a.Overrides) | ||
| @@ -97,7 +97,6 @@ type ThemeOverrides struct { | |||||
| 97 | SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"` | 97 | SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"` | ||
| 98 | SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"` | 98 | SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"` | ||
| 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | ||
| 100 | - | SpinnerMessages []string `json:"spinner_messages,omitempty"` | |||
| 101 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | 100 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | ||
| 102 | SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"` | 101 | SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"` | ||
| 103 | Syntax SyntaxThemeOverrides `json:"syntax,omitempty"` | 102 | Syntax SyntaxThemeOverrides `json:"syntax,omitempty"` | ||
| @@ -502,9 +501,6 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||||
| 502 | if len(o.SpinnerFrames) > 0 { | 501 | if len(o.SpinnerFrames) > 0 { | ||
| 503 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | 502 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | ||
| 504 | } | 503 | } | ||
| 505 | - | if len(o.SpinnerMessages) > 0 { | |||
| 506 | - | th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...) | |||
| 507 | - | } | |||
| 508 | if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 { | 504 | if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 { | ||
| 509 | th.SpinnerIntervalMS = *o.SpinnerIntervalMS | 505 | th.SpinnerIntervalMS = *o.SpinnerIntervalMS | ||
| 510 | } | 506 | } | ||
M packages/tui/theme_loader_test.go +8 -11
| @@ -36,8 +36,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 36 | 36 | if th.FG != Dark.FG { | |
| 37 | 37 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) | |
| 38 | 38 | } | |
| 39 | - | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | |
| 40 | - | t.Fatal("spinner defaults should be inherited") | |
| 39 | + | if len(th.SpinnerFrames) == 0 { | |
| 40 | + | t.Fatal("spinner frames should be inherited") | |
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| @@ -53,7 +53,7 @@ func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | |||
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | - | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |
| 56 | + | func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) { | |
| 57 | 57 | home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`) | |
| 58 | 58 | ||
| 59 | 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | |
| @@ -63,8 +63,8 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |||
| 63 | 63 | if got := len(th.SpinnerFrames); got != 2 { | |
| 64 | 64 | t.Fatalf("spinner frame count = %d, want 2", got) | |
| 65 | 65 | } | |
| 66 | - | if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 { | |
| 67 | - | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | |
| 66 | + | if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 { | |
| 67 | + | t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | |
| 68 | 68 | } | |
| 69 | 69 | if th.Accent != Dark.Accent { | |
| 70 | 70 | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) | |
| @@ -81,9 +81,6 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||
| 81 | 81 | if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" { | |
| 82 | 82 | t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames) | |
| 83 | 83 | } | |
| 84 | - | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" { | |
| 85 | - | t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages) | |
| 86 | - | } | |
| 87 | 84 | if th.SpinnerIntervalMS != 120 { | |
| 88 | 85 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | |
| 89 | 86 | } | |
| @@ -92,7 +89,7 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||
| 92 | 89 | } | |
| 93 | 90 | } | |
| 94 | 91 | ||
| 95 | - | func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | |
| 92 | + | func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) { | |
| 96 | 93 | home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`) | |
| 97 | 94 | ||
| 98 | 95 | th, _, err := LoadThemeFromHome(home, "shared", Light) | |
| @@ -102,8 +99,8 @@ func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | |||
| 102 | 99 | if th.Accent != Color256(204) { | |
| 103 | 100 | t.Fatalf("accent = %#v, want 204", th.Accent) | |
| 104 | 101 | } | |
| 105 | - | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | |
| 106 | - | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | |
| 102 | + | if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS { | |
| 103 | + | t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | |
| 107 | 104 | } | |
| 108 | 105 | if th.FG != Light.FG { | |
| 109 | 106 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | |
| @@ -36,8 +36,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||||
| 36 | if th.FG != Dark.FG { | 36 | if th.FG != Dark.FG { | ||
| 37 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) | 37 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) | ||
| 38 | } | 38 | } | ||
| 39 | - | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | 39 | + | if len(th.SpinnerFrames) == 0 { |
| 40 | - | t.Fatal("spinner defaults should be inherited") | 40 | + | t.Fatal("spinner frames should be inherited") |
| 41 | } | 41 | } | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| @@ -53,7 +53,7 @@ func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | |||||
| 53 | } | 53 | } | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | 56 | + | func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) { |
| 57 | home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`) | 57 | home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`) | ||
| 58 | 58 | ||||
| 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | ||
| @@ -63,8 +63,8 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |||||
| 63 | if got := len(th.SpinnerFrames); got != 2 { | 63 | if got := len(th.SpinnerFrames); got != 2 { | ||
| 64 | t.Fatalf("spinner frame count = %d, want 2", got) | 64 | t.Fatalf("spinner frame count = %d, want 2", got) | ||
| 65 | } | 65 | } | ||
| 66 | - | if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 { | 66 | + | if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 { |
| 67 | - | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | 67 | + | t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) |
| 68 | } | 68 | } | ||
| 69 | if th.Accent != Dark.Accent { | 69 | if th.Accent != Dark.Accent { | ||
| 70 | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) | 70 | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) | ||
| @@ -81,9 +81,6 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||||
| 81 | if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" { | 81 | if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" { | ||
| 82 | t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames) | 82 | t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames) | ||
| 83 | } | 83 | } | ||
| 84 | - | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" { | |||
| 85 | - | t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages) | |||
| 86 | - | } | |||
| 87 | if th.SpinnerIntervalMS != 120 { | 84 | if th.SpinnerIntervalMS != 120 { | ||
| 88 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | 85 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | ||
| 89 | } | 86 | } | ||
| @@ -92,7 +89,7 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||||
| 92 | } | 89 | } | ||
| 93 | } | 90 | } | ||
| 94 | 91 | ||||
| 95 | - | func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | 92 | + | func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) { |
| 96 | home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`) | 93 | home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`) | ||
| 97 | 94 | ||||
| 98 | th, _, err := LoadThemeFromHome(home, "shared", Light) | 95 | th, _, err := LoadThemeFromHome(home, "shared", Light) | ||
| @@ -102,8 +99,8 @@ func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | |||||
| 102 | if th.Accent != Color256(204) { | 99 | if th.Accent != Color256(204) { | ||
| 103 | t.Fatalf("accent = %#v, want 204", th.Accent) | 100 | t.Fatalf("accent = %#v, want 204", th.Accent) | ||
| 104 | } | 101 | } | ||
| 105 | - | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | 102 | + | if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS { |
| 106 | - | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | 103 | + | t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) |
| 107 | } | 104 | } | ||
| 108 | if th.FG != Light.FG { | 105 | if th.FG != Light.FG { | ||
| 109 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | 106 | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | ||