feat(modes): show fact-driven activity
- commit
- 29820a224e7b550e9d99f729350138ba2c3c7873
- author
- brice · · 2026-08-06 07:45 UTC
- parent
- 42a5079
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
40 files · +1078 -195
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/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.activity.kind = activityPreparingRequest | |
| 115 | + | a.pendingToolCalls = nil | |
| 116 | + | a.toolOrder = nil | |
| 117 | + | case core.EvRequestStarted: | |
| 118 | + | a.activity.kind = activitySendingRequest | |
| 119 | + | if e.Provider != "" { | |
| 120 | + | a.activity.provider = e.Provider | |
| 121 | + | } | |
| 122 | + | if e.Model != "" { | |
| 123 | + | a.activity.model = e.Model | |
| 124 | + | } | |
| 125 | + | case core.EvAssistantStart: | |
| 126 | + | a.activity.kind = activityWaitingForResponse | |
| 127 | + | case core.EvTextDelta: | |
| 128 | + | a.activity.kind = activityReceivingResponse | |
| 129 | + | case core.EvToolUseStart: | |
| 130 | + | a.activity.kind = activityPreparingTool | |
| 131 | + | a.activity.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.activity.kind = activityPreparingTool | |
| 141 | + | a.activity.tool = e.Name | |
| 142 | + | case core.EvToolExecutionStarted: | |
| 143 | + | a.activity.kind = activityRunningTool | |
| 144 | + | a.activity.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.activity.kind = activitySendingToolResults | |
| 152 | + | a.activity.tool = "" | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | for _, id := range a.toolOrder { | |
| 156 | + | if name, ok := a.pendingToolCalls[id]; ok { | |
| 157 | + | a.activity.kind = activityPreparingTool | |
| 158 | + | a.activity.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.activity.kind = activityRetryingRequest | |
| 171 | + | a.activity.attempt = e.Attempt | |
| 172 | + | a.activity.maxTries = e.MaxAttempts | |
| 173 | + | a.activity.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.activity.kind = activityPreparingRequest | |||
| 115 | + | a.pendingToolCalls = nil | |||
| 116 | + | a.toolOrder = nil | |||
| 117 | + | case core.EvRequestStarted: | |||
| 118 | + | a.activity.kind = activitySendingRequest | |||
| 119 | + | if e.Provider != "" { | |||
| 120 | + | a.activity.provider = e.Provider | |||
| 121 | + | } | |||
| 122 | + | if e.Model != "" { | |||
| 123 | + | a.activity.model = e.Model | |||
| 124 | + | } | |||
| 125 | + | case core.EvAssistantStart: | |||
| 126 | + | a.activity.kind = activityWaitingForResponse | |||
| 127 | + | case core.EvTextDelta: | |||
| 128 | + | a.activity.kind = activityReceivingResponse | |||
| 129 | + | case core.EvToolUseStart: | |||
| 130 | + | a.activity.kind = activityPreparingTool | |||
| 131 | + | a.activity.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.activity.kind = activityPreparingTool | |||
| 141 | + | a.activity.tool = e.Name | |||
| 142 | + | case core.EvToolExecutionStarted: | |||
| 143 | + | a.activity.kind = activityRunningTool | |||
| 144 | + | a.activity.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.activity.kind = activitySendingToolResults | |||
| 152 | + | a.activity.tool = "" | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | for _, id := range a.toolOrder { | |||
| 156 | + | if name, ok := a.pendingToolCalls[id]; ok { | |||
| 157 | + | a.activity.kind = activityPreparingTool | |||
| 158 | + | a.activity.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.activity.kind = activityRetryingRequest | |||
| 171 | + | a.activity.attempt = e.Attempt | |||
| 172 | + | a.activity.maxTries = e.MaxAttempts | |||
| 173 | + | a.activity.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 +40 -17
| @@ -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,12 @@ type btwDialog struct { | |||
| 38 | 39 | compactMode bool | |
| 39 | 40 | lineInput bool | |
| 40 | 41 | cancel context.CancelFunc | |
| 42 | + | generation uint64 | |
| 41 | 43 | ||
| 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 | |
| 44 | + | // spin drives the same braille animation as the main status bar. Its | |
| 45 | + | // activity label is owned by this dialog under d.mu. | |
| 46 | + | spin *spinner | |
| 47 | + | activity agentActivity | |
| 48 | 48 | ||
| 49 | 49 | // sideAgent owns an isolated transcript seeded from the main | |
| 50 | 50 | // conversation. It uses the same tools and runtime hooks as the | |
| @@ -104,6 +104,7 @@ func (d *btwDialog) SetToolPreview(id, summary, content string) bool { | |||
| 104 | 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | |
| 105 | 105 | tool.Args = summary | |
| 106 | 106 | tool.Preview = content | |
| 107 | + | d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name} | |
| 107 | 108 | return true | |
| 108 | 109 | } | |
| 109 | 110 | } | |
| @@ -130,6 +131,7 @@ func (d *btwDialog) ToggleToolExpansion() { | |||
| 130 | 131 | // host redraw loop can pick up the update without polling. | |
| 131 | 132 | func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) { | |
| 132 | 133 | d.mu.Lock() | |
| 134 | + | d.generation++ | |
| 133 | 135 | d.active = true | |
| 134 | 136 | d.theme = th | |
| 135 | 137 | d.compactMode = compactMode | |
| @@ -138,6 +140,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||
| 138 | 140 | d.turns = nil | |
| 139 | 141 | d.loading = false | |
| 140 | 142 | d.cancel = nil | |
| 143 | + | d.activity = agentActivity{} | |
| 141 | 144 | prompt := th.AccentBar(th.Accent) | |
| 142 | 145 | if lineInput { | |
| 143 | 146 | prompt = "" | |
| @@ -156,10 +159,12 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||
| 156 | 159 | // Close hides the dialog. Cancels any in-flight request. | |
| 157 | 160 | func (d *btwDialog) Close() { | |
| 158 | 161 | d.mu.Lock() | |
| 162 | + | d.generation++ | |
| 159 | 163 | d.active = false | |
| 160 | 164 | d.turns = nil | |
| 161 | 165 | d.editor = nil | |
| 162 | 166 | d.loading = false | |
| 167 | + | d.activity = agentActivity{} | |
| 163 | 168 | cancel := d.cancel | |
| 164 | 169 | d.cancel = nil | |
| 165 | 170 | d.sideAgent = nil | |
| @@ -247,15 +252,25 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 247 | 252 | d.spin.Start() | |
| 248 | 253 | d.turns = append(d.turns, btwTurn{User: question}) | |
| 249 | 254 | turnIdx := len(d.turns) - 1 | |
| 255 | + | generation := d.generation | |
| 250 | 256 | ||
| 251 | 257 | ctx, cancel := context.WithCancel(context.Background()) | |
| 252 | 258 | d.cancel = cancel | |
| 253 | 259 | agent := d.sideAgent | |
| 260 | + | providerName := "" | |
| 261 | + | model := "" | |
| 262 | + | if agent != nil { | |
| 263 | + | model = agent.Model | |
| 264 | + | if agent.Client != nil { | |
| 265 | + | providerName = agent.Client.Name() | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | d.activity = newAgentActivity(providerName, model) | |
| 254 | 269 | d.mu.Unlock() | |
| 255 | 270 | ||
| 256 | 271 | go func() { | |
| 257 | 272 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | |
| 258 | - | d.handleAgentEvent(turnIdx, ev) | |
| 273 | + | d.handleAgentEvent(turnIdx, generation, ev) | |
| 259 | 274 | if invalidate != nil { | |
| 260 | 275 | invalidate() | |
| 261 | 276 | } | |
| @@ -264,7 +279,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 264 | 279 | if err != nil && ctx.Err() == nil { | |
| 265 | 280 | errMsg = err.Error() | |
| 266 | 281 | } | |
| 267 | - | d.completeTurn(turnIdx, errMsg) | |
| 282 | + | d.completeTurn(turnIdx, generation, errMsg) | |
| 268 | 283 | if invalidate != nil { | |
| 269 | 284 | invalidate() | |
| 270 | 285 | } | |
| @@ -278,7 +293,12 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||
| 278 | 293 | agent.Temperature = main.Temperature | |
| 279 | 294 | agent.FastMode = main.FastModeEnabled() | |
| 280 | 295 | agent.MaxTokens = main.MaxTokens | |
| 281 | - | agent.BeforeToolExecute = main.BeforeToolExecute | |
| 296 | + | if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil { | |
| 297 | + | agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 298 | + | call.Origin = "btw" | |
| 299 | + | return beforeToolExecute(call) | |
| 300 | + | } | |
| 301 | + | } | |
| 282 | 302 | agent.BeforeTurn = main.BeforeTurn | |
| 283 | 303 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | |
| 284 | 304 | agent.MaxRetries = main.MaxRetries | |
| @@ -288,13 +308,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||
| 288 | 308 | return agent | |
| 289 | 309 | } | |
| 290 | 310 | ||
| 291 | - | func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) { | |
| 311 | + | func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) { | |
| 292 | 312 | d.mu.Lock() | |
| 293 | 313 | defer d.mu.Unlock() | |
| 294 | - | if turnIdx < 0 || turnIdx >= len(d.turns) { | |
| 314 | + | if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) { | |
| 295 | 315 | return | |
| 296 | 316 | } | |
| 297 | 317 | turn := &d.turns[turnIdx] | |
| 318 | + | d.activity.apply(ev) | |
| 298 | 319 | switch e := ev.(type) { | |
| 299 | 320 | case core.EvTextDelta: | |
| 300 | 321 | turn.Assistant += e.Delta | |
| @@ -353,14 +374,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView { | |||
| 353 | 374 | } | |
| 354 | 375 | ||
| 355 | 376 | // completeTurn records an error, if any, and clears the loading state. | |
| 356 | - | func (d *btwDialog) completeTurn(idx int, errMsg string) { | |
| 377 | + | func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) { | |
| 357 | 378 | d.mu.Lock() | |
| 358 | 379 | defer d.mu.Unlock() | |
| 359 | - | if idx < 0 || idx >= len(d.turns) { | |
| 380 | + | if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) { | |
| 360 | 381 | return | |
| 361 | 382 | } | |
| 362 | 383 | d.turns[idx].Err = errMsg | |
| 363 | 384 | d.loading = false | |
| 385 | + | d.activity = agentActivity{} | |
| 364 | 386 | d.cancel = nil | |
| 365 | 387 | } | |
| 366 | 388 | ||
| @@ -408,12 +430,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 408 | 430 | ||
| 409 | 431 | if d.loading && d.spin != nil { | |
| 410 | 432 | 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. | |
| 433 | + | label := d.activity.label() | |
| 434 | + | if label == "" { | |
| 435 | + | label = "Preparing request" | |
| 436 | + | } | |
| 414 | 437 | prefix := fmt.Sprintf("%s %s - %s", | |
| 415 | 438 | th.FGColor(th.Assistant, d.spin.Frame()), | |
| 416 | - | th.FGColor(th.Assistant, d.spin.Message()), | |
| 439 | + | th.FGColor(th.Assistant, label), | |
| 417 | 440 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | |
| 418 | 441 | ) | |
| 419 | 442 | 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,12 @@ 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 | |||
| 41 | 43 | ||||
| 42 | - | // spin drives the same braille animation + rotating funny-line | 44 | + | // 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 | 45 | + | // activity label is owned by this dialog under d.mu. |
| 44 | - | // is independent of the main spinner (so re-opening the dialog | 46 | + | spin *spinner |
| 45 | - | // always starts fresh and the message doesn't carry over from a | 47 | + | activity agentActivity |
| 46 | - | // completed main turn). | |||
| 47 | - | spin *spinner | |||
| 48 | 48 | ||||
| 49 | // sideAgent owns an isolated transcript seeded from the main | 49 | // sideAgent owns an isolated transcript seeded from the main | ||
| 50 | // conversation. It uses the same tools and runtime hooks as the | 50 | // conversation. It uses the same tools and runtime hooks as the | ||
| @@ -104,6 +104,7 @@ func (d *btwDialog) SetToolPreview(id, summary, content string) bool { | |||||
| 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | ||
| 105 | tool.Args = summary | 105 | tool.Args = summary | ||
| 106 | tool.Preview = content | 106 | tool.Preview = content | ||
| 107 | + | d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name} | |||
| 107 | return true | 108 | return true | ||
| 108 | } | 109 | } | ||
| 109 | } | 110 | } | ||
| @@ -130,6 +131,7 @@ func (d *btwDialog) ToggleToolExpansion() { | |||||
| 130 | // host redraw loop can pick up the update without polling. | 131 | // 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()) { | 132 | 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() | 133 | d.mu.Lock() | ||
| 134 | + | d.generation++ | |||
| 133 | d.active = true | 135 | d.active = true | ||
| 134 | d.theme = th | 136 | d.theme = th | ||
| 135 | d.compactMode = compactMode | 137 | d.compactMode = compactMode | ||
| @@ -138,6 +140,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||||
| 138 | d.turns = nil | 140 | d.turns = nil | ||
| 139 | d.loading = false | 141 | d.loading = false | ||
| 140 | d.cancel = nil | 142 | d.cancel = nil | ||
| 143 | + | d.activity = agentActivity{} | |||
| 141 | prompt := th.AccentBar(th.Accent) | 144 | prompt := th.AccentBar(th.Accent) | ||
| 142 | if lineInput { | 145 | if lineInput { | ||
| 143 | prompt = "" | 146 | prompt = "" | ||
| @@ -156,10 +159,12 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||||
| 156 | // Close hides the dialog. Cancels any in-flight request. | 159 | // Close hides the dialog. Cancels any in-flight request. | ||
| 157 | func (d *btwDialog) Close() { | 160 | func (d *btwDialog) Close() { | ||
| 158 | d.mu.Lock() | 161 | d.mu.Lock() | ||
| 162 | + | d.generation++ | |||
| 159 | d.active = false | 163 | d.active = false | ||
| 160 | d.turns = nil | 164 | d.turns = nil | ||
| 161 | d.editor = nil | 165 | d.editor = nil | ||
| 162 | d.loading = false | 166 | d.loading = false | ||
| 167 | + | d.activity = agentActivity{} | |||
| 163 | cancel := d.cancel | 168 | cancel := d.cancel | ||
| 164 | d.cancel = nil | 169 | d.cancel = nil | ||
| 165 | d.sideAgent = nil | 170 | d.sideAgent = nil | ||
| @@ -247,15 +252,25 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 247 | d.spin.Start() | 252 | d.spin.Start() | ||
| 248 | d.turns = append(d.turns, btwTurn{User: question}) | 253 | d.turns = append(d.turns, btwTurn{User: question}) | ||
| 249 | turnIdx := len(d.turns) - 1 | 254 | turnIdx := len(d.turns) - 1 | ||
| 255 | + | generation := d.generation | |||
| 250 | 256 | ||||
| 251 | ctx, cancel := context.WithCancel(context.Background()) | 257 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 252 | d.cancel = cancel | 258 | d.cancel = cancel | ||
| 253 | agent := d.sideAgent | 259 | agent := d.sideAgent | ||
| 260 | + | providerName := "" | |||
| 261 | + | model := "" | |||
| 262 | + | if agent != nil { | |||
| 263 | + | model = agent.Model | |||
| 264 | + | if agent.Client != nil { | |||
| 265 | + | providerName = agent.Client.Name() | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | d.activity = newAgentActivity(providerName, model) | |||
| 254 | d.mu.Unlock() | 269 | d.mu.Unlock() | ||
| 255 | 270 | ||||
| 256 | go func() { | 271 | go func() { | ||
| 257 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | 272 | err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) { | ||
| 258 | - | d.handleAgentEvent(turnIdx, ev) | 273 | + | d.handleAgentEvent(turnIdx, generation, ev) |
| 259 | if invalidate != nil { | 274 | if invalidate != nil { | ||
| 260 | invalidate() | 275 | invalidate() | ||
| 261 | } | 276 | } | ||
| @@ -264,7 +279,7 @@ func (d *btwDialog) submit(invalidate func()) { | |||||
| 264 | if err != nil && ctx.Err() == nil { | 279 | if err != nil && ctx.Err() == nil { | ||
| 265 | errMsg = err.Error() | 280 | errMsg = err.Error() | ||
| 266 | } | 281 | } | ||
| 267 | - | d.completeTurn(turnIdx, errMsg) | 282 | + | d.completeTurn(turnIdx, generation, errMsg) |
| 268 | if invalidate != nil { | 283 | if invalidate != nil { | ||
| 269 | invalidate() | 284 | invalidate() | ||
| 270 | } | 285 | } | ||
| @@ -278,7 +293,12 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||||
| 278 | agent.Temperature = main.Temperature | 293 | agent.Temperature = main.Temperature | ||
| 279 | agent.FastMode = main.FastModeEnabled() | 294 | agent.FastMode = main.FastModeEnabled() | ||
| 280 | agent.MaxTokens = main.MaxTokens | 295 | agent.MaxTokens = main.MaxTokens | ||
| 281 | - | agent.BeforeToolExecute = main.BeforeToolExecute | 296 | + | if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil { |
| 297 | + | agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 298 | + | call.Origin = "btw" | |||
| 299 | + | return beforeToolExecute(call) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 282 | agent.BeforeTurn = main.BeforeTurn | 302 | agent.BeforeTurn = main.BeforeTurn | ||
| 283 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | 303 | agent.BeforeAssistantMessage = main.BeforeAssistantMessage | ||
| 284 | agent.MaxRetries = main.MaxRetries | 304 | agent.MaxRetries = main.MaxRetries | ||
| @@ -288,13 +308,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||||
| 288 | return agent | 308 | return agent | ||
| 289 | } | 309 | } | ||
| 290 | 310 | ||||
| 291 | - | func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) { | 311 | + | func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) { |
| 292 | d.mu.Lock() | 312 | d.mu.Lock() | ||
| 293 | defer d.mu.Unlock() | 313 | defer d.mu.Unlock() | ||
| 294 | - | if turnIdx < 0 || turnIdx >= len(d.turns) { | 314 | + | if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) { |
| 295 | return | 315 | return | ||
| 296 | } | 316 | } | ||
| 297 | turn := &d.turns[turnIdx] | 317 | turn := &d.turns[turnIdx] | ||
| 318 | + | d.activity.apply(ev) | |||
| 298 | switch e := ev.(type) { | 319 | switch e := ev.(type) { | ||
| 299 | case core.EvTextDelta: | 320 | case core.EvTextDelta: | ||
| 300 | turn.Assistant += e.Delta | 321 | turn.Assistant += e.Delta | ||
| @@ -353,14 +374,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView { | |||||
| 353 | } | 374 | } | ||
| 354 | 375 | ||||
| 355 | // completeTurn records an error, if any, and clears the loading state. | 376 | // completeTurn records an error, if any, and clears the loading state. | ||
| 356 | - | func (d *btwDialog) completeTurn(idx int, errMsg string) { | 377 | + | func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) { |
| 357 | d.mu.Lock() | 378 | d.mu.Lock() | ||
| 358 | defer d.mu.Unlock() | 379 | defer d.mu.Unlock() | ||
| 359 | - | if idx < 0 || idx >= len(d.turns) { | 380 | + | if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) { |
| 360 | return | 381 | return | ||
| 361 | } | 382 | } | ||
| 362 | d.turns[idx].Err = errMsg | 383 | d.turns[idx].Err = errMsg | ||
| 363 | d.loading = false | 384 | d.loading = false | ||
| 385 | + | d.activity = agentActivity{} | |||
| 364 | d.cancel = nil | 386 | d.cancel = nil | ||
| 365 | } | 387 | } | ||
| 366 | 388 | ||||
| @@ -408,12 +430,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||||
| 408 | 430 | ||||
| 409 | if d.loading && d.spin != nil { | 431 | if d.loading && d.spin != nil { | ||
| 410 | out = append(out, "") | 432 | out = append(out, "") | ||
| 411 | - | // Match the main chat busy prefix shape: spinner glyph, | 433 | + | label := d.activity.label() |
| 412 | - | // rotating funny-line, elapsed seconds, then a muted hint | 434 | + | if label == "" { |
| 413 | - | // that esc cancels. | 435 | + | label = "Preparing request" |
| 436 | + | } | |||
| 414 | prefix := fmt.Sprintf("%s %s - %s", | 437 | prefix := fmt.Sprintf("%s %s - %s", | ||
| 415 | th.FGColor(th.Assistant, d.spin.Frame()), | 438 | th.FGColor(th.Assistant, d.spin.Frame()), | ||
| 416 | - | th.FGColor(th.Assistant, d.spin.Message()), | 439 | + | th.FGColor(th.Assistant, label), |
| 417 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | 440 | th.FGColor(th.Muted, d.spin.Elapsed().String()), | ||
| 418 | ) | 441 | ) | ||
| 419 | out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)")) | 442 | 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 != "btw" { | |
| 161 | + | t.Fatalf("side-chat tool policy origin = %q, want btw", policyOrigin) | |
| 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 != "btw" { | |||
| 161 | + | t.Fatalf("side-chat tool policy origin = %q, want btw", policyOrigin) | |||
| 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/confirm_dialog_test.go +4 -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: "btw", | |
| 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 | |
| @@ -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: "btw", | |||
| 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 | ||
M packages/agent/modes/interactive.go +25 -16
| @@ -524,6 +524,7 @@ type Interactive struct { | |||
| 524 | 524 | cumUsage provider.Usage | |
| 525 | 525 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 526 | 526 | busy bool | |
| 527 | + | activity agentActivity | |
| 527 | 528 | pendingIdleWork []func() | |
| 528 | 529 | dirty chan struct{} | |
| 529 | 530 | modelRefresh chan modelRefreshResult | |
| @@ -1766,16 +1767,17 @@ func (i *Interactive) redraw() { | |||
| 1766 | 1767 | suggest = append([]string{""}, suggest...) | |
| 1767 | 1768 | } | |
| 1768 | 1769 | ||
| 1769 | - | // Busy prefix shown at the far left of the status bar. The | |
| 1770 | - | // spinner glyph and its funny-line message share the `zut` | |
| 1771 | - | // label colour (Theme.Assistant) so the whole "who's working" | |
| 1772 | - | // band reads at a glance. Elapsed time stays muted because it | |
| 1773 | - | // drifts every second and shouldn't grab focus. | |
| 1770 | + | // Busy prefix shown at the far left of the status bar. The spinner owns | |
| 1771 | + | // only its frame; the activity label reports the operation in progress. | |
| 1774 | 1772 | var busyPrefix string | |
| 1775 | 1773 | if i.busy { | |
| 1774 | + | label := i.activity.label() | |
| 1775 | + | if label == "" { | |
| 1776 | + | label = "Preparing request" | |
| 1777 | + | } | |
| 1776 | 1778 | busyPrefix = fmt.Sprintf("%s %s %s %s", | |
| 1777 | 1779 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | |
| 1778 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()), | |
| 1780 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), | |
| 1779 | 1781 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | |
| 1780 | 1782 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 1781 | 1783 | ) | |
| @@ -5393,7 +5395,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5393 | 5395 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 5394 | 5396 | i.mu.Lock() | |
| 5395 | 5397 | i.modelRefreshing = true | |
| 5396 | - | i.statusOK = "refreshing models" | |
| 5398 | + | i.statusOK = "Refreshing models" | |
| 5397 | 5399 | i.statusErr = "" | |
| 5398 | 5400 | i.mu.Unlock() | |
| 5399 | 5401 | go func() { | |
| @@ -5592,7 +5594,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 5592 | 5594 | if refreshErr != nil { | |
| 5593 | 5595 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 5594 | 5596 | i.statusOK = "" | |
| 5595 | - | } else if i.statusOK == "refreshing models" { | |
| 5597 | + | } else if i.statusOK == "Refreshing models" { | |
| 5596 | 5598 | i.statusOK = "" | |
| 5597 | 5599 | } | |
| 5598 | 5600 | i.mu.Unlock() | |
| @@ -6418,11 +6420,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6418 | 6420 | i.mu.Lock() | |
| 6419 | 6421 | i.busy = true | |
| 6420 | 6422 | i.compacting = true | |
| 6423 | + | i.spin.Start() | |
| 6424 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 6421 | 6425 | if auto { | |
| 6422 | - | i.spin.StartFixed("condensing history") | |
| 6426 | + | i.activity.kind = activityCondensingHistory | |
| 6423 | 6427 | i.autoCompacting = true | |
| 6424 | 6428 | } else { | |
| 6425 | - | i.spin.StartFixed("compacting") | |
| 6429 | + | i.activity.kind = activityCompactingHistory | |
| 6426 | 6430 | } | |
| 6427 | 6431 | i.cancelTurn = cancel | |
| 6428 | 6432 | i.statusErr = "" | |
| @@ -6618,7 +6622,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 6618 | 6622 | i.cancelTurn = cancel | |
| 6619 | 6623 | i.statusErr = "" | |
| 6620 | 6624 | i.statusOK = "" | |
| 6621 | - | i.spin.StartFixed("running shell command") | |
| 6625 | + | i.spin.Start() | |
| 6626 | + | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |
| 6622 | 6627 | // Clear stale extension notes the same way a new turn would so the | |
| 6623 | 6628 | // screen doesn't accumulate transient state. | |
| 6624 | 6629 | i.extNotes = nil | |
| @@ -6751,6 +6756,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6751 | 6756 | i.mu.Lock() | |
| 6752 | 6757 | i.busy = true | |
| 6753 | 6758 | i.spin.Start() | |
| 6759 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |
| 6754 | 6760 | i.cancelTurn = cancel | |
| 6755 | 6761 | i.statusErr = "" | |
| 6756 | 6762 | i.statusOK = "" | |
| @@ -7126,6 +7132,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||
| 7126 | 7132 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 7127 | 7133 | i.mu.Lock() | |
| 7128 | 7134 | defer i.mu.Unlock() | |
| 7135 | + | i.activity.apply(ev) | |
| 7129 | 7136 | switch e := ev.(type) { | |
| 7130 | 7137 | case core.EvAssistantStart: | |
| 7131 | 7138 | // Fires at the top of every oneTurn, including follow-up | |
| @@ -7348,7 +7355,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 7348 | 7355 | return | |
| 7349 | 7356 | } | |
| 7350 | 7357 | i.mu.Lock() | |
| 7351 | - | i.statusOK = "reloading extensions..." | |
| 7358 | + | i.statusOK = "Reloading extensions..." | |
| 7352 | 7359 | i.statusErr = "" | |
| 7353 | 7360 | i.mu.Unlock() | |
| 7354 | 7361 | i.invalidate() | |
| @@ -7379,16 +7386,18 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||
| 7379 | 7386 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 7380 | 7387 | resp := make(chan core.ConfirmDecision, 1) | |
| 7381 | 7388 | returnToChild := false | |
| 7382 | - | if call.ID != "" { | |
| 7389 | + | if call.ID != "" && call.Origin == "btw" { | |
| 7390 | + | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 7391 | + | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |
| 7392 | + | } | |
| 7393 | + | } else if call.ID != "" { | |
| 7383 | 7394 | i.mu.Lock() | |
| 7384 | 7395 | if tc, ok := i.toolCalls[call.ID]; ok { | |
| 7385 | 7396 | tc.Args = call.Summary | |
| 7386 | 7397 | tc.Preview = call.Content | |
| 7398 | + | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |
| 7387 | 7399 | } | |
| 7388 | 7400 | i.mu.Unlock() | |
| 7389 | - | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 7390 | - | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |
| 7391 | - | } | |
| 7392 | 7401 | } | |
| 7393 | 7402 | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 7394 | 7403 | toolName: call.Name, | |
| @@ -524,6 +524,7 @@ type Interactive struct { | |||||
| 524 | cumUsage provider.Usage | 524 | cumUsage provider.Usage | ||
| 525 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 525 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 526 | busy bool | 526 | busy bool | ||
| 527 | + | activity agentActivity | |||
| 527 | pendingIdleWork []func() | 528 | pendingIdleWork []func() | ||
| 528 | dirty chan struct{} | 529 | dirty chan struct{} | ||
| 529 | modelRefresh chan modelRefreshResult | 530 | modelRefresh chan modelRefreshResult | ||
| @@ -1766,16 +1767,17 @@ func (i *Interactive) redraw() { | |||||
| 1766 | suggest = append([]string{""}, suggest...) | 1767 | suggest = append([]string{""}, suggest...) | ||
| 1767 | } | 1768 | } | ||
| 1768 | 1769 | ||||
| 1769 | - | // Busy prefix shown at the far left of the status bar. The | 1770 | + | // Busy prefix shown at the far left of the status bar. The spinner owns |
| 1770 | - | // spinner glyph and its funny-line message share the `zut` | 1771 | + | // only its frame; the activity label reports the operation in progress. |
| 1771 | - | // label colour (Theme.Assistant) so the whole "who's working" | |||
| 1772 | - | // band reads at a glance. Elapsed time stays muted because it | |||
| 1773 | - | // drifts every second and shouldn't grab focus. | |||
| 1774 | var busyPrefix string | 1772 | var busyPrefix string | ||
| 1775 | if i.busy { | 1773 | if i.busy { | ||
| 1774 | + | label := i.activity.label() | |||
| 1775 | + | if label == "" { | |||
| 1776 | + | label = "Preparing request" | |||
| 1777 | + | } | |||
| 1776 | busyPrefix = fmt.Sprintf("%s %s %s %s", | 1778 | busyPrefix = fmt.Sprintf("%s %s %s %s", | ||
| 1777 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | 1779 | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()), | ||
| 1778 | - | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()), | 1780 | + | i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label), |
| 1779 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | 1781 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"), | ||
| 1780 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | 1782 | i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | ||
| 1781 | ) | 1783 | ) | ||
| @@ -5393,7 +5395,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5393 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | 5395 | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | ||
| 5394 | i.mu.Lock() | 5396 | i.mu.Lock() | ||
| 5395 | i.modelRefreshing = true | 5397 | i.modelRefreshing = true | ||
| 5396 | - | i.statusOK = "refreshing models" | 5398 | + | i.statusOK = "Refreshing models" |
| 5397 | i.statusErr = "" | 5399 | i.statusErr = "" | ||
| 5398 | i.mu.Unlock() | 5400 | i.mu.Unlock() | ||
| 5399 | go func() { | 5401 | go func() { | ||
| @@ -5592,7 +5594,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||||
| 5592 | if refreshErr != nil { | 5594 | if refreshErr != nil { | ||
| 5593 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | 5595 | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | ||
| 5594 | i.statusOK = "" | 5596 | i.statusOK = "" | ||
| 5595 | - | } else if i.statusOK == "refreshing models" { | 5597 | + | } else if i.statusOK == "Refreshing models" { |
| 5596 | i.statusOK = "" | 5598 | i.statusOK = "" | ||
| 5597 | } | 5599 | } | ||
| 5598 | i.mu.Unlock() | 5600 | i.mu.Unlock() | ||
| @@ -6418,11 +6420,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6418 | i.mu.Lock() | 6420 | i.mu.Lock() | ||
| 6419 | i.busy = true | 6421 | i.busy = true | ||
| 6420 | i.compacting = true | 6422 | i.compacting = true | ||
| 6423 | + | i.spin.Start() | |||
| 6424 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |||
| 6421 | if auto { | 6425 | if auto { | ||
| 6422 | - | i.spin.StartFixed("condensing history") | 6426 | + | i.activity.kind = activityCondensingHistory |
| 6423 | i.autoCompacting = true | 6427 | i.autoCompacting = true | ||
| 6424 | } else { | 6428 | } else { | ||
| 6425 | - | i.spin.StartFixed("compacting") | 6429 | + | i.activity.kind = activityCompactingHistory |
| 6426 | } | 6430 | } | ||
| 6427 | i.cancelTurn = cancel | 6431 | i.cancelTurn = cancel | ||
| 6428 | i.statusErr = "" | 6432 | i.statusErr = "" | ||
| @@ -6618,7 +6622,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 6618 | i.cancelTurn = cancel | 6622 | i.cancelTurn = cancel | ||
| 6619 | i.statusErr = "" | 6623 | i.statusErr = "" | ||
| 6620 | i.statusOK = "" | 6624 | i.statusOK = "" | ||
| 6621 | - | i.spin.StartFixed("running shell command") | 6625 | + | i.spin.Start() |
| 6626 | + | i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}} | |||
| 6622 | // Clear stale extension notes the same way a new turn would so the | 6627 | // Clear stale extension notes the same way a new turn would so the | ||
| 6623 | // screen doesn't accumulate transient state. | 6628 | // screen doesn't accumulate transient state. | ||
| 6624 | i.extNotes = nil | 6629 | i.extNotes = nil | ||
| @@ -6751,6 +6756,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6751 | i.mu.Lock() | 6756 | i.mu.Lock() | ||
| 6752 | i.busy = true | 6757 | i.busy = true | ||
| 6753 | i.spin.Start() | 6758 | i.spin.Start() | ||
| 6759 | + | i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model) | |||
| 6754 | i.cancelTurn = cancel | 6760 | i.cancelTurn = cancel | ||
| 6755 | i.statusErr = "" | 6761 | i.statusErr = "" | ||
| 6756 | i.statusOK = "" | 6762 | i.statusOK = "" | ||
| @@ -7126,6 +7132,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||||
| 7126 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | 7132 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | ||
| 7127 | i.mu.Lock() | 7133 | i.mu.Lock() | ||
| 7128 | defer i.mu.Unlock() | 7134 | defer i.mu.Unlock() | ||
| 7135 | + | i.activity.apply(ev) | |||
| 7129 | switch e := ev.(type) { | 7136 | switch e := ev.(type) { | ||
| 7130 | case core.EvAssistantStart: | 7137 | case core.EvAssistantStart: | ||
| 7131 | // Fires at the top of every oneTurn, including follow-up | 7138 | // Fires at the top of every oneTurn, including follow-up | ||
| @@ -7348,7 +7355,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||||
| 7348 | return | 7355 | return | ||
| 7349 | } | 7356 | } | ||
| 7350 | i.mu.Lock() | 7357 | i.mu.Lock() | ||
| 7351 | - | i.statusOK = "reloading extensions..." | 7358 | + | i.statusOK = "Reloading extensions..." |
| 7352 | i.statusErr = "" | 7359 | i.statusErr = "" | ||
| 7353 | i.mu.Unlock() | 7360 | i.mu.Unlock() | ||
| 7354 | i.invalidate() | 7361 | i.invalidate() | ||
| @@ -7379,16 +7386,18 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||||
| 7379 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | 7386 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | ||
| 7380 | resp := make(chan core.ConfirmDecision, 1) | 7387 | resp := make(chan core.ConfirmDecision, 1) | ||
| 7381 | returnToChild := false | 7388 | returnToChild := false | ||
| 7382 | - | if call.ID != "" { | 7389 | + | if call.ID != "" && call.Origin == "btw" { |
| 7390 | + | if i.btwDialog != nil && i.btwDialog.Active() { | |||
| 7391 | + | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |||
| 7392 | + | } | |||
| 7393 | + | } else if call.ID != "" { | |||
| 7383 | i.mu.Lock() | 7394 | i.mu.Lock() | ||
| 7384 | if tc, ok := i.toolCalls[call.ID]; ok { | 7395 | if tc, ok := i.toolCalls[call.ID]; ok { | ||
| 7385 | tc.Args = call.Summary | 7396 | tc.Args = call.Summary | ||
| 7386 | tc.Preview = call.Content | 7397 | tc.Preview = call.Content | ||
| 7398 | + | i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} | |||
| 7387 | } | 7399 | } | ||
| 7388 | i.mu.Unlock() | 7400 | i.mu.Unlock() | ||
| 7389 | - | if i.btwDialog != nil && i.btwDialog.Active() { | |||
| 7390 | - | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |||
| 7391 | - | } | |||
| 7392 | } | 7401 | } | ||
| 7393 | i.confirmDialog.Enqueue(&confirmRequest{ | 7402 | i.confirmDialog.Enqueue(&confirmRequest{ | ||
| 7394 | toolName: call.Name, | 7403 | toolName: call.Name, | ||
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/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. | |
| @@ -90,23 +56,6 @@ func (s *spinner) Frame() string { | |||
| 90 | 56 | return s.frames[idx] | |
| 91 | 57 | } | |
| 92 | 58 | ||
| 93 | - | // Message returns the spinner's status text. One random phrase | |
| 94 | - | // per Start call, pinned until the next turn. When the spinner | |
| 95 | - | // was started via StartFixed, the pinned message is returned | |
| 96 | - | // unchanged. | |
| 97 | - | func (s *spinner) Message() string { | |
| 98 | - | if s.fixedMsg != "" { | |
| 99 | - | return s.fixedMsg | |
| 100 | - | } | |
| 101 | - | if len(s.messages) == 0 { | |
| 102 | - | return "thinking" | |
| 103 | - | } | |
| 104 | - | if s.msgIdx < 0 || s.msgIdx >= len(s.messages) { | |
| 105 | - | s.msgIdx = 0 | |
| 106 | - | } | |
| 107 | - | return s.messages[s.msgIdx] | |
| 108 | - | } | |
| 109 | - | ||
| 110 | 59 | // Elapsed returns the wall-clock duration the spinner has been running. | |
| 111 | 60 | func (s *spinner) Elapsed() time.Duration { | |
| 112 | 61 | 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. | ||
| @@ -90,23 +56,6 @@ func (s *spinner) Frame() string { | |||||
| 90 | return s.frames[idx] | 56 | return s.frames[idx] | ||
| 91 | } | 57 | } | ||
| 92 | 58 | ||||
| 93 | - | // Message returns the spinner's status text. One random phrase | |||
| 94 | - | // per Start call, pinned until the next turn. When the spinner | |||
| 95 | - | // was started via StartFixed, the pinned message is returned | |||
| 96 | - | // unchanged. | |||
| 97 | - | func (s *spinner) Message() string { | |||
| 98 | - | if s.fixedMsg != "" { | |||
| 99 | - | return s.fixedMsg | |||
| 100 | - | } | |||
| 101 | - | if len(s.messages) == 0 { | |||
| 102 | - | return "thinking" | |||
| 103 | - | } | |||
| 104 | - | if s.msgIdx < 0 || s.msgIdx >= len(s.messages) { | |||
| 105 | - | s.msgIdx = 0 | |||
| 106 | - | } | |||
| 107 | - | return s.messages[s.msgIdx] | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | // Elapsed returns the wall-clock duration the spinner has been running. | 59 | // Elapsed returns the wall-clock duration the spinner has been running. | ||
| 111 | func (s *spinner) Elapsed() time.Duration { | 60 | func (s *spinner) Elapsed() time.Duration { | ||
| 112 | if s.startedAt.IsZero() { | 61 | 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/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 | } | ||
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) | ||