detached b75ebb7

merge(modes): integrate main into subagent activity

commit
b75ebb7059f347a6756c6ce79ca12cae19d897b0
author
brice · · 2026-08-06 12:37 UTC
merge of
5858f9a + 5c1a9ec · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
48 files · +1717 -256
M docs/rpc.md +12 -2
@@ -192,10 +192,16 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
192192 | `type` | Fields | Meaning |
193193 |---|---|---|
194194 | `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 |
195197 | `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 |
197199 | `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 |
199205 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
200206 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
201207 | `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`.
209215 | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
210216 | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
211217
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+
212222 ## Message shape
213223
214224 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 shape222 ## Message shape
213 223
214 Used by `get_messages` and inside `user_message` / `assistant_message` events.224 Used by `get_messages` and inside `user_message` / `assistant_message` events.
M docs/subagents.md +2 -0
@@ -96,6 +96,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa
9696
9797 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
9898
99+If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
100+
99101 ```text
100102 subagent://<id>
101103 subagent://<id>/history
@@ -96,6 +96,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa
96 96
97 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:97 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
98 98
99+If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
100+
99 ```text101 ```text
100 subagent://<id>102 subagent://<id>
101 subagent://<id>/history103 subagent://<id>/history
M docs/themes.md +8 -6
@@ -105,7 +105,6 @@ Spinner-only:
105105 "name": "custom-spinner",
106106 "description": "Only changes the busy spinner.",
107107 "spinner_frames": ["◢", "◣", "◤", "◥"],
108- "spinner_messages": ["working"],
109108 "spinner_interval_ms": 120
110109 }
111110 ```
@@ -117,11 +116,10 @@ light default. The inverse also works.
117116 ```json
118117 {
119118 "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.",
121120 "colors": {
122121 "dark": {
123122 "spinner_frames": ["◢", "◣", "◤", "◥"],
124- "spinner_messages": ["working"],
125123 "spinner_interval_ms": 120
126124 }
127125 }
@@ -157,7 +155,6 @@ All fields are optional.
157155 "selection_bg": 24,
158156 "selection_fg": 231,
159157 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
160- "spinner_messages": ["thinking", "working"],
161158 "spinner_interval_ms": 80,
162159 "syntax_base_style": "monokai",
163160 "syntax": {
@@ -198,7 +195,7 @@ You may also put overrides directly at the top level or directly under
198195 "name": "tiny",
199196 "accent": 204,
200197 "colors": {
201- "spinner_messages": ["shipping"]
198+ "spinner_frames": ["◐", "◓", "◑", "◒"]
202199 }
203200 }
204201 ```
@@ -249,10 +246,15 @@ Spinner settings can appear at top level, under `colors`, or under
249246
250247 - `spinner_frames` — list of frame strings. Single-cell glyphs keep
251248 status-bar alignment clean.
252-- `spinner_messages` — list of messages; zut picks one per turn.
253249 - `spinner_interval_ms` — frame interval in milliseconds. Missing or
254250 invalid falls back to 80ms.
255251
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+
256258 ## Syntax fields
257259
258260 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": 120108 "spinner_interval_ms": 120
110 }109 }
111 ```110 ```
@@ -117,11 +116,10 @@ light default. The inverse also works.
117 ```json116 ```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": 120123 "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 keep247 - `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 or249 - `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 fields258 ## Syntax fields
257 259
258 Syntax highlighting uses Chroma style entries. Values may include260 Syntax highlighting uses Chroma style entries. Values may include
M examples/themes/custom-spinner.json +1 -4
@@ -1,6 +1,6 @@
11 {
22 "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.",
44 "colors": {
55 "dark": {
66 "spinner_frames": [
@@ -13,9 +13,6 @@
1313 "\u28EF",
1414 "\u28F7"
1515 ],
16- "spinner_messages": [
17- "working"
18- ],
1916 "spinner_interval_ms": 120
2017 }
2118 }
@@ -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": 12016 "spinner_interval_ms": 120
20 }17 }
21 }18 }
M examples/themes/vercel.json +0 -14
@@ -62,13 +62,6 @@
6262 "\u25d1",
6363 "\u25d2"
6464 ],
65- "spinner_messages": [
66- "shipping pixels",
67- "deploying thoughts",
68- "warming edge cache",
69- "building preview",
70- "checking redirects"
71- ],
7265 "spinner_interval_ms": 120
7366 },
7467 "light": {
@@ -113,13 +106,6 @@
113106 "\u25d1",
114107 "\u25d2"
115108 ],
116- "spinner_messages": [
117- "shipping pixels",
118- "deploying thoughts",
119- "warming edge cache",
120- "building preview",
121- "checking redirects"
122- ],
123109 "spinner_interval_ms": 120
124110 }
125111 }
@@ -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": 12065 "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": 120109 "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 {
14921492 Name: call.Name,
14931493 Summary: core.BuildPreview(effectiveArgs, 120),
14941494 Content: content.String(),
1495+ Origin: call.Origin,
14951496 })
14961497 if !ok {
14971498 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, nil1498 return false, reason, nil
A packages/agent/modes/activity.go +175 -0
@@ -0,0 +1,175 @@
1+package modes
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "time"
7+ "unicode"
8+
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// activityKind describes a user-visible operation. It is deliberately local
13+// to interactive modes: public consumers receive factual core events instead
14+// of rendered UI labels.
15+type activityKind string
16+
17+const (
18+ activityIdle activityKind = "idle"
19+ activityPreparingRequest activityKind = "preparing_request"
20+ activitySendingRequest activityKind = "sending_request"
21+ activityWaitingForResponse activityKind = "waiting_for_response"
22+ activityReceivingResponse activityKind = "receiving_response"
23+ activityPreparingTool activityKind = "preparing_tool"
24+ activityAwaitingConfirmation activityKind = "awaiting_confirmation"
25+ activityRunningTool activityKind = "running_tool"
26+ activitySendingToolResults activityKind = "sending_tool_results"
27+ activityRetryingRequest activityKind = "retrying_request"
28+ activityCompactingHistory activityKind = "compacting_history"
29+ activityCondensingHistory activityKind = "condensing_history"
30+ activityRunningShellCommand activityKind = "running_shell_command"
31+)
32+
33+type activity struct {
34+ kind activityKind
35+ provider string
36+ model string
37+ tool string
38+ attempt int
39+ maxTries int
40+ retryIn time.Duration
41+}
42+
43+func (a activity) label() string {
44+ switch a.kind {
45+ case activityPreparingRequest:
46+ return "Preparing request"
47+ case activitySendingRequest:
48+ return "Sending request to " + activityText(a.provider, "provider")
49+ case activityWaitingForResponse:
50+ return "Waiting for " + activityText(a.model, "model") + " to respond"
51+ case activityReceivingResponse:
52+ return "Receiving response from " + activityText(a.model, "model")
53+ case activityPreparingTool:
54+ return "Preparing tool: " + activityText(a.tool, "tool")
55+ case activityAwaitingConfirmation:
56+ return "Waiting for approval: " + activityText(a.tool, "tool")
57+ case activityRunningTool:
58+ return "Running tool: " + activityText(a.tool, "tool")
59+ case activitySendingToolResults:
60+ return "Sending tool results to " + activityText(a.provider, "provider")
61+ case activityRetryingRequest:
62+ if a.attempt <= 0 || a.maxTries <= 0 {
63+ return "Retrying request"
64+ }
65+ return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")"
66+ case activityCompactingHistory:
67+ return "Compacting conversation history"
68+ case activityCondensingHistory:
69+ return "Condensing conversation history"
70+ case activityRunningShellCommand:
71+ return "Running shell command"
72+ default:
73+ return ""
74+ }
75+}
76+
77+func activityText(value, fallback string) string {
78+ value = strings.Map(func(r rune) rune {
79+ if unicode.IsControl(r) {
80+ return -1
81+ }
82+ return r
83+ }, value)
84+ value = strings.TrimSpace(value)
85+ if value == "" {
86+ return fallback
87+ }
88+ return value
89+}
90+
91+// agentActivity reduces agent lifecycle facts into one current activity. It
92+// tracks all pending tool calls so a multi-tool round does not claim results
93+// are being sent while another tool still needs to run.
94+type agentActivity struct {
95+ activity
96+ pendingToolCalls map[string]string
97+ toolOrder []string
98+}
99+
100+func newAgentActivity(provider, model string) agentActivity {
101+ return agentActivity{activity: activity{
102+ kind: activityPreparingRequest,
103+ provider: provider,
104+ model: model,
105+ }}
106+}
107+
108+func (a *agentActivity) apply(ev core.AgentEvent) {
109+ if a == nil {
110+ return
111+ }
112+ switch e := ev.(type) {
113+ case core.EvTurnStart:
114+ a.kind = activityPreparingRequest
115+ a.pendingToolCalls = nil
116+ a.toolOrder = nil
117+ case core.EvRequestStarted:
118+ a.kind = activitySendingRequest
119+ if e.Provider != "" {
120+ a.provider = e.Provider
121+ }
122+ if e.Model != "" {
123+ a.model = e.Model
124+ }
125+ case core.EvAssistantStart:
126+ a.kind = activityWaitingForResponse
127+ case core.EvTextDelta:
128+ a.kind = activityReceivingResponse
129+ case core.EvToolUseStart:
130+ a.kind = activityPreparingTool
131+ a.tool = e.Name
132+ case core.EvToolCall:
133+ if a.pendingToolCalls == nil {
134+ a.pendingToolCalls = make(map[string]string)
135+ }
136+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
137+ a.toolOrder = append(a.toolOrder, e.ID)
138+ }
139+ a.pendingToolCalls[e.ID] = e.Name
140+ a.kind = activityPreparingTool
141+ a.tool = e.Name
142+ case core.EvToolExecutionStarted:
143+ a.kind = activityRunningTool
144+ a.tool = e.Name
145+ case core.EvToolResult:
146+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
147+ return
148+ }
149+ delete(a.pendingToolCalls, e.ID)
150+ if len(a.pendingToolCalls) == 0 {
151+ a.kind = activitySendingToolResults
152+ a.tool = ""
153+ return
154+ }
155+ for _, id := range a.toolOrder {
156+ if name, ok := a.pendingToolCalls[id]; ok {
157+ a.kind = activityPreparingTool
158+ a.tool = name
159+ return
160+ }
161+ }
162+ case core.EvRetryScheduled:
163+ // Agent retries discard the preceding partial assistant message.
164+ // Its proposed tool calls cannot run, so they must not affect the
165+ // next successful attempt's batch accounting.
166+ if e.Scope == core.RetryScopeAgent {
167+ a.pendingToolCalls = nil
168+ a.toolOrder = nil
169+ }
170+ a.kind = activityRetryingRequest
171+ a.attempt = e.Attempt
172+ a.maxTries = e.MaxAttempts
173+ a.retryIn = e.Delay
174+ }
175+}
@@ -0,0 +1,175 @@
1+package modes
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "time"
7+ "unicode"
8+
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// activityKind describes a user-visible operation. It is deliberately local
13+// to interactive modes: public consumers receive factual core events instead
14+// of rendered UI labels.
15+type activityKind string
16+
17+const (
18+ activityIdle activityKind = "idle"
19+ activityPreparingRequest activityKind = "preparing_request"
20+ activitySendingRequest activityKind = "sending_request"
21+ activityWaitingForResponse activityKind = "waiting_for_response"
22+ activityReceivingResponse activityKind = "receiving_response"
23+ activityPreparingTool activityKind = "preparing_tool"
24+ activityAwaitingConfirmation activityKind = "awaiting_confirmation"
25+ activityRunningTool activityKind = "running_tool"
26+ activitySendingToolResults activityKind = "sending_tool_results"
27+ activityRetryingRequest activityKind = "retrying_request"
28+ activityCompactingHistory activityKind = "compacting_history"
29+ activityCondensingHistory activityKind = "condensing_history"
30+ activityRunningShellCommand activityKind = "running_shell_command"
31+)
32+
33+type activity struct {
34+ kind activityKind
35+ provider string
36+ model string
37+ tool string
38+ attempt int
39+ maxTries int
40+ retryIn time.Duration
41+}
42+
43+func (a activity) label() string {
44+ switch a.kind {
45+ case activityPreparingRequest:
46+ return "Preparing request"
47+ case activitySendingRequest:
48+ return "Sending request to " + activityText(a.provider, "provider")
49+ case activityWaitingForResponse:
50+ return "Waiting for " + activityText(a.model, "model") + " to respond"
51+ case activityReceivingResponse:
52+ return "Receiving response from " + activityText(a.model, "model")
53+ case activityPreparingTool:
54+ return "Preparing tool: " + activityText(a.tool, "tool")
55+ case activityAwaitingConfirmation:
56+ return "Waiting for approval: " + activityText(a.tool, "tool")
57+ case activityRunningTool:
58+ return "Running tool: " + activityText(a.tool, "tool")
59+ case activitySendingToolResults:
60+ return "Sending tool results to " + activityText(a.provider, "provider")
61+ case activityRetryingRequest:
62+ if a.attempt <= 0 || a.maxTries <= 0 {
63+ return "Retrying request"
64+ }
65+ return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")"
66+ case activityCompactingHistory:
67+ return "Compacting conversation history"
68+ case activityCondensingHistory:
69+ return "Condensing conversation history"
70+ case activityRunningShellCommand:
71+ return "Running shell command"
72+ default:
73+ return ""
74+ }
75+}
76+
77+func activityText(value, fallback string) string {
78+ value = strings.Map(func(r rune) rune {
79+ if unicode.IsControl(r) {
80+ return -1
81+ }
82+ return r
83+ }, value)
84+ value = strings.TrimSpace(value)
85+ if value == "" {
86+ return fallback
87+ }
88+ return value
89+}
90+
91+// agentActivity reduces agent lifecycle facts into one current activity. It
92+// tracks all pending tool calls so a multi-tool round does not claim results
93+// are being sent while another tool still needs to run.
94+type agentActivity struct {
95+ activity
96+ pendingToolCalls map[string]string
97+ toolOrder []string
98+}
99+
100+func newAgentActivity(provider, model string) agentActivity {
101+ return agentActivity{activity: activity{
102+ kind: activityPreparingRequest,
103+ provider: provider,
104+ model: model,
105+ }}
106+}
107+
108+func (a *agentActivity) apply(ev core.AgentEvent) {
109+ if a == nil {
110+ return
111+ }
112+ switch e := ev.(type) {
113+ case core.EvTurnStart:
114+ a.kind = activityPreparingRequest
115+ a.pendingToolCalls = nil
116+ a.toolOrder = nil
117+ case core.EvRequestStarted:
118+ a.kind = activitySendingRequest
119+ if e.Provider != "" {
120+ a.provider = e.Provider
121+ }
122+ if e.Model != "" {
123+ a.model = e.Model
124+ }
125+ case core.EvAssistantStart:
126+ a.kind = activityWaitingForResponse
127+ case core.EvTextDelta:
128+ a.kind = activityReceivingResponse
129+ case core.EvToolUseStart:
130+ a.kind = activityPreparingTool
131+ a.tool = e.Name
132+ case core.EvToolCall:
133+ if a.pendingToolCalls == nil {
134+ a.pendingToolCalls = make(map[string]string)
135+ }
136+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
137+ a.toolOrder = append(a.toolOrder, e.ID)
138+ }
139+ a.pendingToolCalls[e.ID] = e.Name
140+ a.kind = activityPreparingTool
141+ a.tool = e.Name
142+ case core.EvToolExecutionStarted:
143+ a.kind = activityRunningTool
144+ a.tool = e.Name
145+ case core.EvToolResult:
146+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
147+ return
148+ }
149+ delete(a.pendingToolCalls, e.ID)
150+ if len(a.pendingToolCalls) == 0 {
151+ a.kind = activitySendingToolResults
152+ a.tool = ""
153+ return
154+ }
155+ for _, id := range a.toolOrder {
156+ if name, ok := a.pendingToolCalls[id]; ok {
157+ a.kind = activityPreparingTool
158+ a.tool = name
159+ return
160+ }
161+ }
162+ case core.EvRetryScheduled:
163+ // Agent retries discard the preceding partial assistant message.
164+ // Its proposed tool calls cannot run, so they must not affect the
165+ // next successful attempt's batch accounting.
166+ if e.Scope == core.RetryScopeAgent {
167+ a.pendingToolCalls = nil
168+ a.toolOrder = nil
169+ }
170+ a.kind = activityRetryingRequest
171+ a.attempt = e.Attempt
172+ a.maxTries = e.MaxAttempts
173+ a.retryIn = e.Delay
174+ }
175+}
A packages/agent/modes/activity_test.go +71 -0
@@ -0,0 +1,71 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestAgentActivityDescribesLifecycle(t *testing.T) {
12+ a := newAgentActivity("anthropic", "claude-test")
13+ check := func(want string) {
14+ t.Helper()
15+ if got := a.label(); got != want {
16+ t.Fatalf("label = %q, want %q", got, want)
17+ }
18+ }
19+
20+ check("Preparing request")
21+ a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
22+ check("Sending request to anthropic")
23+ a.apply(core.EvAssistantStart{})
24+ check("Waiting for claude-test to respond")
25+ a.apply(core.EvTextDelta{Delta: "hello"})
26+ check("Receiving response from claude-test")
27+ a.apply(core.EvToolUseStart{ID: "one", Name: "read"})
28+ check("Preparing tool: read")
29+ a.apply(core.EvToolCall{ID: "one", Name: "read"})
30+ a.apply(core.EvToolCall{ID: "two", Name: "bash"})
31+ a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"})
32+ check("Running tool: read")
33+ a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}})
34+ check("Preparing tool: bash")
35+ a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"})
36+ a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}})
37+ check("Sending tool results to anthropic")
38+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second})
39+ check("Retrying request in 2s (attempt 2/4)")
40+}
41+
42+func TestActivityLabelsSanitizeAndFallback(t *testing.T) {
43+ a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"}
44+ if got, want := a.label(), "Sending request to [31mremote"; got != want {
45+ t.Fatalf("sanitized label = %q, want %q", got, want)
46+ }
47+ a = activity{kind: activityRunningTool}
48+ if got, want := a.label(), "Running tool: tool"; got != want {
49+ t.Fatalf("fallback label = %q, want %q", got, want)
50+ }
51+}
52+
53+func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) {
54+ a := newAgentActivity("provider", "model")
55+ a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}})
56+ if got, want := a.label(), "Preparing request"; got != want {
57+ t.Fatalf("label = %q, want %q", got, want)
58+ }
59+}
60+
61+func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) {
62+ a := newAgentActivity("provider", "model")
63+ a.apply(core.EvToolCall{ID: "discarded", Name: "stale"})
64+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond})
65+ a.apply(core.EvToolCall{ID: "actual", Name: "read"})
66+ a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}})
67+
68+ if got, want := a.label(), "Sending tool results to provider"; got != want {
69+ t.Fatalf("label = %q, want %q", got, want)
70+ }
71+}
@@ -0,0 +1,71 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestAgentActivityDescribesLifecycle(t *testing.T) {
12+ a := newAgentActivity("anthropic", "claude-test")
13+ check := func(want string) {
14+ t.Helper()
15+ if got := a.label(); got != want {
16+ t.Fatalf("label = %q, want %q", got, want)
17+ }
18+ }
19+
20+ check("Preparing request")
21+ a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
22+ check("Sending request to anthropic")
23+ a.apply(core.EvAssistantStart{})
24+ check("Waiting for claude-test to respond")
25+ a.apply(core.EvTextDelta{Delta: "hello"})
26+ check("Receiving response from claude-test")
27+ a.apply(core.EvToolUseStart{ID: "one", Name: "read"})
28+ check("Preparing tool: read")
29+ a.apply(core.EvToolCall{ID: "one", Name: "read"})
30+ a.apply(core.EvToolCall{ID: "two", Name: "bash"})
31+ a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"})
32+ check("Running tool: read")
33+ a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}})
34+ check("Preparing tool: bash")
35+ a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"})
36+ a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}})
37+ check("Sending tool results to anthropic")
38+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second})
39+ check("Retrying request in 2s (attempt 2/4)")
40+}
41+
42+func TestActivityLabelsSanitizeAndFallback(t *testing.T) {
43+ a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"}
44+ if got, want := a.label(), "Sending request to [31mremote"; got != want {
45+ t.Fatalf("sanitized label = %q, want %q", got, want)
46+ }
47+ a = activity{kind: activityRunningTool}
48+ if got, want := a.label(), "Running tool: tool"; got != want {
49+ t.Fatalf("fallback label = %q, want %q", got, want)
50+ }
51+}
52+
53+func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) {
54+ a := newAgentActivity("provider", "model")
55+ a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}})
56+ if got, want := a.label(), "Preparing request"; got != want {
57+ t.Fatalf("label = %q, want %q", got, want)
58+ }
59+}
60+
61+func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) {
62+ a := newAgentActivity("provider", "model")
63+ a.apply(core.EvToolCall{ID: "discarded", Name: "stale"})
64+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond})
65+ a.apply(core.EvToolCall{ID: "actual", Name: "read"})
66+ a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}})
67+
68+ if got, want := a.label(), "Sending tool results to provider"; got != want {
69+ t.Fatalf("label = %q, want %q", got, want)
70+ }
71+}
M packages/agent/modes/btw_dialog.go +74 -23
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
67 "strings"
78 "sync"
@@ -38,13 +39,13 @@ type btwDialog struct {
3839 compactMode bool
3940 lineInput bool
4041 cancel context.CancelFunc
42+ generation uint64
43+ closeHook func()
4144
42- // spin drives the same braille animation + rotating funny-line
43- // shown in the main status bar. Owned by the dialog so its clock
44- // is independent of the main spinner (so re-opening the dialog
45- // always starts fresh and the message doesn't carry over from a
46- // completed main turn).
47- spin *spinner
45+ // spin drives the same braille animation as the main status bar. Its
46+ // activity label is owned by this dialog under d.mu.
47+ spin *spinner
48+ activity agentActivity
4849
4950 // sideAgent owns an isolated transcript seeded from the main
5051 // conversation. It uses the same tools and runtime hooks as the
@@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog {
6869 return &btwDialog{}
6970 }
7071
72+func btwOrigin(generation uint64) string {
73+ return fmt.Sprintf("btw:%d", generation)
74+}
75+
76+func isBtwOrigin(origin string) bool {
77+ return strings.HasPrefix(origin, "btw:")
78+}
79+
80+func (d *btwDialog) setCloseHook(hook func()) {
81+ if d == nil {
82+ return
83+ }
84+ d.mu.Lock()
85+ d.closeHook = hook
86+ d.mu.Unlock()
87+}
88+
7189 // Active reports whether the dialog is visible and consuming keys.
7290 func (d *btwDialog) Active() bool {
7391 if d == nil {
@@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool {
92110 return d.active && d.loading
93111 }
94112
95-// SetToolPreview attaches the side-effect-free confirmation preview to the
96-// matching side-chat tool box before execution begins.
97-func (d *btwDialog) SetToolPreview(id, summary, content string) bool {
98- if d == nil || id == "" {
113+// enqueueToolConfirmation attaches the side-effect-free confirmation preview
114+// to the matching side-chat tool box and queues its prompt while d.mu is
115+// held. Keeping both actions together prevents Close from advancing the
116+// generation between the ownership check and the confirmation enqueue.
117+func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool {
118+ if d == nil || id == "" || enqueue == nil {
99119 return false
100120 }
101121 d.mu.Lock()
102122 defer d.mu.Unlock()
123+ if !d.active || origin != btwOrigin(d.generation) {
124+ return false
125+ }
103126 for turnIdx := range d.turns {
104127 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105128 tool.Args = summary
106129 tool.Preview = content
130+ d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name}
131+ enqueue()
107132 return true
108133 }
109134 }
@@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() {
130155 // host redraw loop can pick up the update without polling.
131156 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
132157 d.mu.Lock()
158+ d.generation++
133159 d.active = true
134160 d.theme = th
135161 d.compactMode = compactMode
@@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
138164 d.turns = nil
139165 d.loading = false
140166 d.cancel = nil
167+ d.activity = agentActivity{}
141168 prompt := th.AccentBar(th.Accent)
142169 if lineInput {
143170 prompt = ""
144171 }
145172 d.editor = tui.NewEditor(prompt)
146- d.sideAgent = newBtwAgent(agent, system, model)
173+ d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation))
147174 d.cwd = cwd
148175 d.mu.Unlock()
149176
@@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
156183 // Close hides the dialog. Cancels any in-flight request.
157184 func (d *btwDialog) Close() {
158185 d.mu.Lock()
186+ d.generation++
159187 d.active = false
160188 d.turns = nil
161189 d.editor = nil
162190 d.loading = false
191+ d.activity = agentActivity{}
163192 cancel := d.cancel
164193 d.cancel = nil
165194 d.sideAgent = nil
166195 d.toolView = nil
196+ closeHook := d.closeHook
167197 d.mu.Unlock()
168198 if cancel != nil {
169199 cancel()
170200 }
201+ if closeHook != nil {
202+ closeHook()
203+ }
171204 }
172205
173206 // HandleKey routes a keypress to the dialog. Returns true if the
@@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) {
247280 d.spin.Start()
248281 d.turns = append(d.turns, btwTurn{User: question})
249282 turnIdx := len(d.turns) - 1
283+ generation := d.generation
250284
251285 ctx, cancel := context.WithCancel(context.Background())
252286 d.cancel = cancel
253287 agent := d.sideAgent
288+ providerName := ""
289+ model := ""
290+ if agent != nil {
291+ model = agent.Model
292+ if agent.Client != nil {
293+ providerName = agent.Client.Name()
294+ }
295+ }
296+ d.activity = newAgentActivity(providerName, model)
254297 d.mu.Unlock()
255298
256299 go func() {
257300 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
258- d.handleAgentEvent(turnIdx, ev)
301+ d.handleAgentEvent(turnIdx, generation, ev)
259302 if invalidate != nil {
260303 invalidate()
261304 }
@@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) {
264307 if err != nil && ctx.Err() == nil {
265308 errMsg = err.Error()
266309 }
267- d.completeTurn(turnIdx, errMsg)
310+ d.completeTurn(turnIdx, generation, errMsg)
268311 if invalidate != nil {
269312 invalidate()
270313 }
271314 }()
272315 }
273316
274-func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
317+func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
275318 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276319 agent.MaxSteps = main.MaxSteps
277320 agent.Reasoning = main.Reasoning
278321 agent.Temperature = main.Temperature
279322 agent.FastMode = main.FastModeEnabled()
280323 agent.MaxTokens = main.MaxTokens
281- agent.BeforeToolExecute = main.BeforeToolExecute
324+ if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil {
325+ agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
326+ call.Origin = origin
327+ return beforeToolExecute(call)
328+ }
329+ }
282330 agent.BeforeTurn = main.BeforeTurn
283331 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
284332 agent.MaxRetries = main.MaxRetries
@@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
288336 return agent
289337 }
290338
291-func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {
339+func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) {
292340 d.mu.Lock()
293341 defer d.mu.Unlock()
294- if turnIdx < 0 || turnIdx >= len(d.turns) {
342+ if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) {
295343 return
296344 }
297345 turn := &d.turns[turnIdx]
346+ d.activity.apply(ev)
298347 switch e := ev.(type) {
299348 case core.EvTextDelta:
300349 turn.Assistant += e.Delta
@@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
353402 }
354403
355404 // completeTurn records an error, if any, and clears the loading state.
356-func (d *btwDialog) completeTurn(idx int, errMsg string) {
405+func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) {
357406 d.mu.Lock()
358407 defer d.mu.Unlock()
359- if idx < 0 || idx >= len(d.turns) {
408+ if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) {
360409 return
361410 }
362411 d.turns[idx].Err = errMsg
363412 d.loading = false
413+ d.activity = agentActivity{}
364414 d.cancel = nil
365415 }
366416
@@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
408458
409459 if d.loading && d.spin != nil {
410460 out = append(out, "")
411- // Match the main chat busy prefix shape: spinner glyph,
412- // rotating funny-line, elapsed seconds, then a muted hint
413- // that esc cancels.
461+ label := d.activity.label()
462+ if label == "" {
463+ label = "Preparing request"
464+ }
414465 prefix := fmt.Sprintf("%s %s - %s",
415466 th.FGColor(th.Assistant, d.spin.Frame()),
416- th.FGColor(th.Assistant, d.spin.Message()),
467+ th.FGColor(th.Assistant, label),
417468 th.FGColor(th.Muted, d.spin.Elapsed().String()),
418469 )
419470 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
6 "strings"7 "strings"
7 "sync"8 "sync"
@@ -38,13 +39,13 @@ type btwDialog struct {
38 compactMode bool39 compactMode bool
39 lineInput bool40 lineInput bool
40 cancel context.CancelFunc41 cancel context.CancelFunc
42+ generation uint64
43+ closeHook func()
41 44
42- // spin drives the same braille animation + rotating funny-line45+ // 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 clock46+ // activity label is owned by this dialog under d.mu.
44- // is independent of the main spinner (so re-opening the dialog47+ spin *spinner
45- // always starts fresh and the message doesn't carry over from a48+ activity agentActivity
46- // completed main turn).
47- spin *spinner
48 49
49 // sideAgent owns an isolated transcript seeded from the main50 // sideAgent owns an isolated transcript seeded from the main
50 // conversation. It uses the same tools and runtime hooks as the51 // conversation. It uses the same tools and runtime hooks as the
@@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog {
68 return &btwDialog{}69 return &btwDialog{}
69 }70 }
70 71
72+func btwOrigin(generation uint64) string {
73+ return fmt.Sprintf("btw:%d", generation)
74+}
75+
76+func isBtwOrigin(origin string) bool {
77+ return strings.HasPrefix(origin, "btw:")
78+}
79+
80+func (d *btwDialog) setCloseHook(hook func()) {
81+ if d == nil {
82+ return
83+ }
84+ d.mu.Lock()
85+ d.closeHook = hook
86+ d.mu.Unlock()
87+}
88+
71 // Active reports whether the dialog is visible and consuming keys.89 // Active reports whether the dialog is visible and consuming keys.
72 func (d *btwDialog) Active() bool {90 func (d *btwDialog) Active() bool {
73 if d == nil {91 if d == nil {
@@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool {
92 return d.active && d.loading110 return d.active && d.loading
93 }111 }
94 112
95-// SetToolPreview attaches the side-effect-free confirmation preview to the113+// enqueueToolConfirmation attaches the side-effect-free confirmation preview
96-// matching side-chat tool box before execution begins.114+// to the matching side-chat tool box and queues its prompt while d.mu is
97-func (d *btwDialog) SetToolPreview(id, summary, content string) bool {115+// held. Keeping both actions together prevents Close from advancing the
98- if d == nil || id == "" {116+// generation between the ownership check and the confirmation enqueue.
117+func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool {
118+ if d == nil || id == "" || enqueue == nil {
99 return false119 return false
100 }120 }
101 d.mu.Lock()121 d.mu.Lock()
102 defer d.mu.Unlock()122 defer d.mu.Unlock()
123+ if !d.active || origin != btwOrigin(d.generation) {
124+ return false
125+ }
103 for turnIdx := range d.turns {126 for turnIdx := range d.turns {
104 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {127 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105 tool.Args = summary128 tool.Args = summary
106 tool.Preview = content129 tool.Preview = content
130+ d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name}
131+ enqueue()
107 return true132 return true
108 }133 }
109 }134 }
@@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() {
130 // host redraw loop can pick up the update without polling.155 // host redraw loop can pick up the update without polling.
131 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {156 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
132 d.mu.Lock()157 d.mu.Lock()
158+ d.generation++
133 d.active = true159 d.active = true
134 d.theme = th160 d.theme = th
135 d.compactMode = compactMode161 d.compactMode = compactMode
@@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
138 d.turns = nil164 d.turns = nil
139 d.loading = false165 d.loading = false
140 d.cancel = nil166 d.cancel = nil
167+ d.activity = agentActivity{}
141 prompt := th.AccentBar(th.Accent)168 prompt := th.AccentBar(th.Accent)
142 if lineInput {169 if lineInput {
143 prompt = ""170 prompt = ""
144 }171 }
145 d.editor = tui.NewEditor(prompt)172 d.editor = tui.NewEditor(prompt)
146- d.sideAgent = newBtwAgent(agent, system, model)173+ d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation))
147 d.cwd = cwd174 d.cwd = cwd
148 d.mu.Unlock()175 d.mu.Unlock()
149 176
@@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
156 // Close hides the dialog. Cancels any in-flight request.183 // Close hides the dialog. Cancels any in-flight request.
157 func (d *btwDialog) Close() {184 func (d *btwDialog) Close() {
158 d.mu.Lock()185 d.mu.Lock()
186+ d.generation++
159 d.active = false187 d.active = false
160 d.turns = nil188 d.turns = nil
161 d.editor = nil189 d.editor = nil
162 d.loading = false190 d.loading = false
191+ d.activity = agentActivity{}
163 cancel := d.cancel192 cancel := d.cancel
164 d.cancel = nil193 d.cancel = nil
165 d.sideAgent = nil194 d.sideAgent = nil
166 d.toolView = nil195 d.toolView = nil
196+ closeHook := d.closeHook
167 d.mu.Unlock()197 d.mu.Unlock()
168 if cancel != nil {198 if cancel != nil {
169 cancel()199 cancel()
170 }200 }
201+ if closeHook != nil {
202+ closeHook()
203+ }
171 }204 }
172 205
173 // HandleKey routes a keypress to the dialog. Returns true if the206 // HandleKey routes a keypress to the dialog. Returns true if the
@@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) {
247 d.spin.Start()280 d.spin.Start()
248 d.turns = append(d.turns, btwTurn{User: question})281 d.turns = append(d.turns, btwTurn{User: question})
249 turnIdx := len(d.turns) - 1282 turnIdx := len(d.turns) - 1
283+ generation := d.generation
250 284
251 ctx, cancel := context.WithCancel(context.Background())285 ctx, cancel := context.WithCancel(context.Background())
252 d.cancel = cancel286 d.cancel = cancel
253 agent := d.sideAgent287 agent := d.sideAgent
288+ providerName := ""
289+ model := ""
290+ if agent != nil {
291+ model = agent.Model
292+ if agent.Client != nil {
293+ providerName = agent.Client.Name()
294+ }
295+ }
296+ d.activity = newAgentActivity(providerName, model)
254 d.mu.Unlock()297 d.mu.Unlock()
255 298
256 go func() {299 go func() {
257 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {300 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
258- d.handleAgentEvent(turnIdx, ev)301+ d.handleAgentEvent(turnIdx, generation, ev)
259 if invalidate != nil {302 if invalidate != nil {
260 invalidate()303 invalidate()
261 }304 }
@@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) {
264 if err != nil && ctx.Err() == nil {307 if err != nil && ctx.Err() == nil {
265 errMsg = err.Error()308 errMsg = err.Error()
266 }309 }
267- d.completeTurn(turnIdx, errMsg)310+ d.completeTurn(turnIdx, generation, errMsg)
268 if invalidate != nil {311 if invalidate != nil {
269 invalidate()312 invalidate()
270 }313 }
271 }()314 }()
272 }315 }
273 316
274-func newBtwAgent(main *core.Agent, system, model string) *core.Agent {317+func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
275 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())318 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276 agent.MaxSteps = main.MaxSteps319 agent.MaxSteps = main.MaxSteps
277 agent.Reasoning = main.Reasoning320 agent.Reasoning = main.Reasoning
278 agent.Temperature = main.Temperature321 agent.Temperature = main.Temperature
279 agent.FastMode = main.FastModeEnabled()322 agent.FastMode = main.FastModeEnabled()
280 agent.MaxTokens = main.MaxTokens323 agent.MaxTokens = main.MaxTokens
281- agent.BeforeToolExecute = main.BeforeToolExecute324+ if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil {
325+ agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
326+ call.Origin = origin
327+ return beforeToolExecute(call)
328+ }
329+ }
282 agent.BeforeTurn = main.BeforeTurn330 agent.BeforeTurn = main.BeforeTurn
283 agent.BeforeAssistantMessage = main.BeforeAssistantMessage331 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
284 agent.MaxRetries = main.MaxRetries332 agent.MaxRetries = main.MaxRetries
@@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
288 return agent336 return agent
289 }337 }
290 338
291-func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {339+func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) {
292 d.mu.Lock()340 d.mu.Lock()
293 defer d.mu.Unlock()341 defer d.mu.Unlock()
294- if turnIdx < 0 || turnIdx >= len(d.turns) {342+ if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) {
295 return343 return
296 }344 }
297 turn := &d.turns[turnIdx]345 turn := &d.turns[turnIdx]
346+ d.activity.apply(ev)
298 switch e := ev.(type) {347 switch e := ev.(type) {
299 case core.EvTextDelta:348 case core.EvTextDelta:
300 turn.Assistant += e.Delta349 turn.Assistant += e.Delta
@@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
353 }402 }
354 403
355 // completeTurn records an error, if any, and clears the loading state.404 // completeTurn records an error, if any, and clears the loading state.
356-func (d *btwDialog) completeTurn(idx int, errMsg string) {405+func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) {
357 d.mu.Lock()406 d.mu.Lock()
358 defer d.mu.Unlock()407 defer d.mu.Unlock()
359- if idx < 0 || idx >= len(d.turns) {408+ if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) {
360 return409 return
361 }410 }
362 d.turns[idx].Err = errMsg411 d.turns[idx].Err = errMsg
363 d.loading = false412 d.loading = false
413+ d.activity = agentActivity{}
364 d.cancel = nil414 d.cancel = nil
365 }415 }
366 416
@@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
408 458
409 if d.loading && d.spin != nil {459 if d.loading && d.spin != nil {
410 out = append(out, "")460 out = append(out, "")
411- // Match the main chat busy prefix shape: spinner glyph,461+ label := d.activity.label()
412- // rotating funny-line, elapsed seconds, then a muted hint462+ if label == "" {
413- // that esc cancels.463+ label = "Preparing request"
464+ }
414 prefix := fmt.Sprintf("%s %s - %s",465 prefix := fmt.Sprintf("%s %s - %s",
415 th.FGColor(th.Assistant, d.spin.Frame()),466 th.FGColor(th.Assistant, d.spin.Frame()),
416- th.FGColor(th.Assistant, d.spin.Message()),467+ th.FGColor(th.Assistant, label),
417 th.FGColor(th.Muted, d.spin.Elapsed().String()),468 th.FGColor(th.Muted, d.spin.Elapsed().String()),
418 )469 )
419 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))470 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
M packages/agent/modes/btw_dialog_test.go +124 -1
@@ -128,8 +128,10 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
128128 Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129129 }})
130130 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) {
132133 policyCalls.Add(1)
134+ policyOrigin = call.Origin
133135 return true, "", nil
134136 }
135137
@@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
155157 if got := policyCalls.Load(); got != 1 {
156158 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157159 }
160+ if policyOrigin != btwOrigin(1) {
161+ t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
162+ }
158163
159164 client.mu.Lock()
160165 requests := append([]provider.Request(nil), client.requests...)
@@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
201206 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202207 }
203208 }
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.Int32130 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, "", nil135 return true, "", nil
134 }136 }
135 137
@@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
155 if got := policyCalls.Load(); got != 1 {157 if got := policyCalls.Load(); got != 1 {
156 t.Fatalf("main agent tool-policy calls = %d, want 1", got)158 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157 }159 }
160+ if policyOrigin != btwOrigin(1) {
161+ t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
162+ }
158 163
159 client.mu.Lock()164 client.mu.Lock()
160 requests := append([]provider.Request(nil), client.requests...)165 requests := append([]provider.Request(nil), client.requests...)
@@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
201 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)206 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202 }207 }
203 }208 }
209+
210+func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
211+ d := &btwDialog{
212+ active: true,
213+ loading: true,
214+ theme: tui.Dark,
215+ toolView: &tui.View{Theme: tui.Dark},
216+ spin: newSpinner(tui.Dark),
217+ activity: newAgentActivity("anthropic", "claude-test"),
218+ }
219+ d.spin.Start()
220+ d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
221+
222+ rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
223+ if !strings.Contains(rendered, "Sending request to anthropic") {
224+ t.Fatalf("/btw activity = %q, want factual request label", rendered)
225+ }
226+ if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
227+ t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
228+ }
229+}
230+
231+func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
232+ d := &btwDialog{
233+ active: true,
234+ loading: true,
235+ generation: 2,
236+ turns: []btwTurn{{User: "new question"}},
237+ activity: newAgentActivity("provider", "model"),
238+ }
239+
240+ d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
241+ d.completeTurn(0, 1, "stale error")
242+
243+ d.mu.Lock()
244+ defer d.mu.Unlock()
245+ if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
246+ t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
247+ }
248+ if !d.loading {
249+ t.Fatal("stale completion cleared the reopened dialog loading state")
250+ }
251+}
252+
253+func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
254+ client := &btwToolTestClient{done: make(chan struct{})}
255+ tool := &btwEchoTool{}
256+ main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
257+ d := newBtwDialog()
258+ i := &Interactive{
259+ btwDialog: d,
260+ confirmDialog: newConfirmDialog(),
261+ dirty: make(chan struct{}, 1),
262+ }
263+ gate := core.NewConfirmGate(i)
264+ mainResp := make(chan core.ConfirmDecision, 1)
265+ i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
266+ i.confirmDialog.Blur()
267+ main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
268+ return gate.CheckToolCall(core.ToolCallConfirmation{
269+ ID: call.ID,
270+ Name: call.Name,
271+ Summary: "hello",
272+ Origin: call.Origin,
273+ })
274+ }
275+
276+ d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
277+ sideConfirmationFirst := func() bool {
278+ i.confirmDialog.mu.Lock()
279+ defer i.confirmDialog.mu.Unlock()
280+ return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
281+ }
282+ deadline := time.Now().Add(2 * time.Second)
283+ for !sideConfirmationFirst() && time.Now().Before(deadline) {
284+ time.Sleep(time.Millisecond)
285+ }
286+ if !sideConfirmationFirst() {
287+ t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
288+ }
289+ if !i.confirmDialog.Focused() {
290+ t.Fatal("side-agent confirmation did not focus the prioritized dialog")
291+ }
292+
293+ d.mu.Lock()
294+ if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
295+ d.mu.Unlock()
296+ t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
297+ }
298+ if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
299+ d.mu.Unlock()
300+ t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
301+ }
302+ if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
303+ d.mu.Unlock()
304+ t.Fatalf("side-agent activity = %q, want %q", got, want)
305+ }
306+ d.mu.Unlock()
307+
308+ i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
309+ select {
310+ case <-client.done:
311+ case <-time.After(2 * time.Second):
312+ t.Fatal("side-agent did not continue after confirmation")
313+ }
314+ if got := tool.runs.Load(); got != 1 {
315+ t.Fatalf("tool executions = %d, want 1 after confirmation", got)
316+ }
317+ if i.confirmDialog.Focused() {
318+ t.Fatal("main confirmation regained focus instead of returning input to /btw")
319+ }
320+ i.confirmDialog.CancelAll("test complete")
321+ select {
322+ case <-mainResp:
323+ default:
324+ t.Fatal("main confirmation was not retained after resolving the side-chat call")
325+ }
326+}
M packages/agent/modes/compact_queue_test.go +0 -24
@@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
430430 }
431431 }
432432
433-func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
434- tests := []struct {
435- name string
436- err error
437- want bool
438- }{
439- {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
440- {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
441- {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
442- {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
443- {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
444- {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
445- {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
446- }
447-
448- for _, tt := range tests {
449- t.Run(tt.name, func(t *testing.T) {
450- if got := isContextOverflowError(tt.err); got != tt.want {
451- t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
452- }
453- })
454- }
455-}
456-
457433 func requestContainsUserText(req provider.Request, want string) bool {
458434 return requestUserTextCount(req, want) > 0
459435 }
@@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
430 }430 }
431 }431 }
432 432
433-func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
434- tests := []struct {
435- name string
436- err error
437- want bool
438- }{
439- {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
440- {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
441- {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
442- {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
443- {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
444- {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
445- {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
446- }
447-
448- for _, tt := range tests {
449- t.Run(tt.name, func(t *testing.T) {
450- if got := isContextOverflowError(tt.err); got != tt.want {
451- t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
452- }
453- })
454- }
455-}
456-
457 func requestContainsUserText(req provider.Request, want string) bool {433 func requestContainsUserText(req provider.Request, want string) bool {
458 return requestUserTextCount(req, want) > 0434 return requestUserTextCount(req, want) > 0
459 }435 }
M packages/agent/modes/confirm_dialog.go +38 -0
@@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) {
121121 }
122122 }
123123
124+// CancelChildConfirmations refuses side-chat confirmations without affecting
125+// queued main-thread calls. Closing /btw must unblock its agent even though
126+// the confirmation UI is no longer visible.
127+func (d *confirmDialog) CancelChildConfirmations(reason string) {
128+ if d == nil {
129+ return
130+ }
131+ d.mu.Lock()
132+ pending := d.pending
133+ remaining := make([]*confirmRequest, 0, len(pending))
134+ cancelled := make([]*confirmRequest, 0, len(pending))
135+ activeCancelled := false
136+ for index, req := range pending {
137+ if req.returnToChild {
138+ cancelled = append(cancelled, req)
139+ activeCancelled = activeCancelled || index == 0
140+ continue
141+ }
142+ remaining = append(remaining, req)
143+ }
144+ d.pending = remaining
145+ if activeCancelled {
146+ d.cursor = 0
147+ d.focused = len(remaining) > 0
148+ if d.focused {
149+ d.activeSince = time.Now()
150+ }
151+ }
152+ d.mu.Unlock()
153+
154+ for _, req := range cancelled {
155+ select {
156+ case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
157+ default:
158+ }
159+ }
160+}
161+
124162 // AllowAllPending approves every pending request and drains the
125163 // queue. Used by /yolo so any confirmation dialog already on screen
126164 // resolves immediately as "yes".
@@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) {
121 }121 }
122 }122 }
123 123
124+// CancelChildConfirmations refuses side-chat confirmations without affecting
125+// queued main-thread calls. Closing /btw must unblock its agent even though
126+// the confirmation UI is no longer visible.
127+func (d *confirmDialog) CancelChildConfirmations(reason string) {
128+ if d == nil {
129+ return
130+ }
131+ d.mu.Lock()
132+ pending := d.pending
133+ remaining := make([]*confirmRequest, 0, len(pending))
134+ cancelled := make([]*confirmRequest, 0, len(pending))
135+ activeCancelled := false
136+ for index, req := range pending {
137+ if req.returnToChild {
138+ cancelled = append(cancelled, req)
139+ activeCancelled = activeCancelled || index == 0
140+ continue
141+ }
142+ remaining = append(remaining, req)
143+ }
144+ d.pending = remaining
145+ if activeCancelled {
146+ d.cursor = 0
147+ d.focused = len(remaining) > 0
148+ if d.focused {
149+ d.activeSince = time.Now()
150+ }
151+ }
152+ d.mu.Unlock()
153+
154+ for _, req := range cancelled {
155+ select {
156+ case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
157+ default:
158+ }
159+ }
160+}
161+
124 // AllowAllPending approves every pending request and drains the162 // AllowAllPending approves every pending request and drains the
125 // queue. Used by /yolo so any confirmation dialog already on screen163 // queue. Used by /yolo so any confirmation dialog already on screen
126 // resolves immediately as "yes".164 // resolves immediately as "yes".
M packages/agent/modes/confirm_dialog_test.go +65 -3
@@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
3939 Name: "edit",
4040 Summary: "sample.go",
4141 Content: "-old\n+new\n",
42+ Origin: btwOrigin(0),
4243 })
4344 }()
4445
@@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
5051 t.Fatal("confirmation dialog did not open")
5152 }
5253 i.mu.Lock()
53- preview := i.toolCalls["call-1"].Preview
54+ mainPreview := i.toolCalls["call-1"].Preview
5455 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)
5758 }
5859 btw.mu.Lock()
5960 btwPreview := btw.turns[0].Tools[0].Preview
@@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
7980 }
8081 }
8182
83+func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) {
84+ i := newConfirmationInputInteractive()
85+ i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil)
86+ i.btwDialog.mu.Lock()
87+ origin := btwOrigin(i.btwDialog.generation)
88+ i.btwDialog.mu.Unlock()
89+ i.btwDialog.Close()
90+
91+ decision := i.ConfirmToolCall(core.ToolCallConfirmation{
92+ ID: "call-1",
93+ Name: "edit",
94+ Origin: origin,
95+ })
96+ if decision.Allow || decision.Reason != "side-chat tool call canceled" {
97+ t.Fatalf("stale side-chat decision = %+v", decision)
98+ }
99+ if i.confirmDialog.Active() {
100+ t.Fatal("stale side-chat confirmation activated the main dialog")
101+ }
102+}
103+
104+func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) {
105+ i := newConfirmationInputInteractive()
106+ d := i.btwDialog
107+ d.mu.Lock()
108+ d.active = true
109+ d.generation = 1
110+ d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}}
111+ d.mu.Unlock()
112+
113+ decision := make(chan core.ConfirmDecision, 1)
114+ go func() {
115+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
116+ ID: "call-1",
117+ Name: "edit",
118+ Origin: btwOrigin(1),
119+ })
120+ }()
121+
122+ deadline := time.Now().Add(time.Second)
123+ for !i.confirmDialog.Active() && time.Now().Before(deadline) {
124+ runtime.Gosched()
125+ }
126+ if !i.confirmDialog.Active() {
127+ t.Fatal("side-chat confirmation did not enter the queue")
128+ }
129+ d.Close()
130+
131+ select {
132+ case got := <-decision:
133+ if got.Allow || got.Reason != "side chat closed" {
134+ t.Fatalf("close decision = %+v", got)
135+ }
136+ case <-time.After(time.Second):
137+ t.Fatal("closing /btw did not unblock its confirmation")
138+ }
139+ if i.confirmDialog.Active() {
140+ t.Fatal("side-chat confirmation remained active after /btw closed")
141+ }
142+}
143+
82144 func newConfirmationInputInteractive() *Interactive {
83145 agent := core.NewAgent(nil, "test-model", "test system", nil)
84146 return NewInteractive(InteractiveConfig{
@@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
39 Name: "edit",39 Name: "edit",
40 Summary: "sample.go",40 Summary: "sample.go",
41 Content: "-old\n+new\n",41 Content: "-old\n+new\n",
42+ Origin: btwOrigin(0),
42 })43 })
43 }()44 }()
44 45
@@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
50 t.Fatal("confirmation dialog did not open")51 t.Fatal("confirmation dialog did not open")
51 }52 }
52 i.mu.Lock()53 i.mu.Lock()
53- preview := i.toolCalls["call-1"].Preview54+ 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].Preview60 btwPreview := btw.turns[0].Tools[0].Preview
@@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
79 }80 }
80 }81 }
81 82
83+func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) {
84+ i := newConfirmationInputInteractive()
85+ i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil)
86+ i.btwDialog.mu.Lock()
87+ origin := btwOrigin(i.btwDialog.generation)
88+ i.btwDialog.mu.Unlock()
89+ i.btwDialog.Close()
90+
91+ decision := i.ConfirmToolCall(core.ToolCallConfirmation{
92+ ID: "call-1",
93+ Name: "edit",
94+ Origin: origin,
95+ })
96+ if decision.Allow || decision.Reason != "side-chat tool call canceled" {
97+ t.Fatalf("stale side-chat decision = %+v", decision)
98+ }
99+ if i.confirmDialog.Active() {
100+ t.Fatal("stale side-chat confirmation activated the main dialog")
101+ }
102+}
103+
104+func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) {
105+ i := newConfirmationInputInteractive()
106+ d := i.btwDialog
107+ d.mu.Lock()
108+ d.active = true
109+ d.generation = 1
110+ d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}}
111+ d.mu.Unlock()
112+
113+ decision := make(chan core.ConfirmDecision, 1)
114+ go func() {
115+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
116+ ID: "call-1",
117+ Name: "edit",
118+ Origin: btwOrigin(1),
119+ })
120+ }()
121+
122+ deadline := time.Now().Add(time.Second)
123+ for !i.confirmDialog.Active() && time.Now().Before(deadline) {
124+ runtime.Gosched()
125+ }
126+ if !i.confirmDialog.Active() {
127+ t.Fatal("side-chat confirmation did not enter the queue")
128+ }
129+ d.Close()
130+
131+ select {
132+ case got := <-decision:
133+ if got.Allow || got.Reason != "side chat closed" {
134+ t.Fatalf("close decision = %+v", got)
135+ }
136+ case <-time.After(time.Second):
137+ t.Fatal("closing /btw did not unblock its confirmation")
138+ }
139+ if i.confirmDialog.Active() {
140+ t.Fatal("side-chat confirmation remained active after /btw closed")
141+ }
142+}
143+
82 func newConfirmationInputInteractive() *Interactive {144 func newConfirmationInputInteractive() *Interactive {
83 agent := core.NewAgent(nil, "test-model", "test system", nil)145 agent := core.NewAgent(nil, "test-model", "test system", nil)
84 return NewInteractive(InteractiveConfig{146 return NewInteractive(InteractiveConfig{
M packages/agent/modes/interactive.go +42 -42
@@ -529,6 +529,7 @@ type Interactive struct {
529529 cumUsage provider.Usage
530530 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
531531 busy bool
532+ activity agentActivity
532533 subagentActivityActive bool
533534 pendingIdleWork []func()
534535 dirty chan struct{}
@@ -813,6 +814,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
813814 clock: time.Now,
814815 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
815816 }
817+ i.btwDialog.setCloseHook(func() {
818+ i.confirmDialog.CancelChildConfirmations("side chat closed")
819+ })
816820 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
817821 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
818822 if cfg.LlamaCPPConfig != nil {
@@ -1773,16 +1777,17 @@ func (i *Interactive) redraw() {
17731777 suggest = append([]string{""}, suggest...)
17741778 }
17751779
1776- // Busy prefix shown at the far left of the status bar. The
1777- // spinner glyph and its funny-line message share the `zut`
1778- // label colour (Theme.Assistant) so the whole "who's working"
1779- // band reads at a glance. Elapsed time stays muted because it
1780- // drifts every second and shouldn't grab focus.
1780+ // Busy prefix shown at the far left of the status bar. The spinner owns
1781+ // only its frame; the activity label reports the operation in progress.
17811782 var busyPrefix string
17821783 if i.busy {
1784+ label := i.activity.label()
1785+ if label == "" {
1786+ label = "Preparing request"
1787+ }
17831788 busyPrefix = fmt.Sprintf("%s %s %s %s",
17841789 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1785- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),
1790+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
17861791 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
17871792 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
17881793 )
@@ -5508,7 +5513,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
55085513 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
55095514 i.mu.Lock()
55105515 i.modelRefreshing = true
5511- i.statusOK = "refreshing models"
5516+ i.statusOK = "Refreshing models"
55125517 i.statusErr = ""
55135518 i.mu.Unlock()
55145519 go func() {
@@ -5707,7 +5712,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
57075712 if refreshErr != nil {
57085713 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
57095714 i.statusOK = ""
5710- } else if i.statusOK == "refreshing models" {
5715+ } else if i.statusOK == "Refreshing models" {
57115716 i.statusOK = ""
57125717 }
57135718 i.mu.Unlock()
@@ -6533,11 +6538,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
65336538 i.mu.Lock()
65346539 i.busy = true
65356540 i.compacting = true
6541+ i.spin.Start()
6542+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
65366543 if auto {
6537- i.spin.StartFixed("condensing history")
6544+ i.activity.kind = activityCondensingHistory
65386545 i.autoCompacting = true
65396546 } else {
6540- i.spin.StartFixed("compacting")
6547+ i.activity.kind = activityCompactingHistory
65416548 }
65426549 i.cancelTurn = cancel
65436550 i.statusErr = ""
@@ -6733,7 +6740,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
67336740 i.cancelTurn = cancel
67346741 i.statusErr = ""
67356742 i.statusOK = ""
6736- i.spin.StartFixed("running shell command")
6743+ i.spin.Start()
6744+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
67376745 // Clear stale extension notes the same way a new turn would so the
67386746 // screen doesn't accumulate transient state.
67396747 i.extNotes = nil
@@ -6866,6 +6874,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
68666874 i.mu.Lock()
68676875 i.busy = true
68686876 i.spin.Start()
6877+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
68696878 i.cancelTurn = cancel
68706879 i.statusErr = ""
68716880 i.statusOK = ""
@@ -6974,7 +6983,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
69746983 // based auto-compact can miss both when metadata is stale or the
69756984 // limit is measured in raw bytes. Compact once, then continue the
69766985 // user message already present in the transcript.
6977- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6986+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
69786987 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
69796988 if recoverContextOverflow {
69806989 i.statusErr = ""
@@ -7148,27 +7157,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
71487157 return " " + th.FGColor(th.Warning, "⚠ "+msg)
71497158 }
71507159
7151-// isContextOverflowError matches provider responses that reject the
7152-// current request because its raw payload or tokenized input is too
7153-// large. Providers surface this as either HTTP 413 or semantic context
7154-// errors with differing codes and messages.
7155-func isContextOverflowError(err error) bool {
7156- if err == nil {
7157- return false
7158- }
7159- msg := strings.ToLower(err.Error())
7160- return strings.Contains(msg, "http 413") ||
7161- strings.Contains(msg, " 413") ||
7162- strings.HasPrefix(msg, "413 ") ||
7163- strings.Contains(msg, "payload too large") ||
7164- strings.Contains(msg, "request entity too large") ||
7165- strings.Contains(msg, "input exceeds the context window") ||
7166- strings.Contains(msg, "context window exceeded") ||
7167- strings.Contains(msg, "maximum context length") ||
7168- strings.Contains(msg, "context_length_exceeded") ||
7169- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7170-}
7171-
71727160 const defaultAutoCompactThreshold = 85
71737161
71747162 func normalizeAutoCompactThreshold(threshold *int) int {
@@ -7241,6 +7229,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
72417229 func (i *Interactive) handleEvent(ev core.AgentEvent) {
72427230 i.mu.Lock()
72437231 defer i.mu.Unlock()
7232+ i.activity.apply(ev)
72447233 switch e := ev.(type) {
72457234 case core.EvAssistantStart:
72467235 // Fires at the top of every oneTurn, including follow-up
@@ -7463,7 +7452,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
74637452 return
74647453 }
74657454 i.mu.Lock()
7466- i.statusOK = "reloading extensions..."
7455+ i.statusOK = "Reloading extensions..."
74677456 i.statusErr = ""
74687457 i.mu.Unlock()
74697458 i.invalidate()
@@ -7493,23 +7482,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis
74937482 // tool panel, then blocks until the user approves or refuses the call.
74947483 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
74957484 resp := make(chan core.ConfirmDecision, 1)
7496- returnToChild := false
7485+ if isBtwOrigin(call.Origin) {
7486+ req := &confirmRequest{
7487+ toolName: call.Name,
7488+ preview: call.Summary,
7489+ resp: resp,
7490+ returnToChild: true,
7491+ }
7492+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7493+ i.confirmDialog.Enqueue(req)
7494+ }) {
7495+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7496+ }
7497+ i.invalidate()
7498+ return <-resp
7499+ }
74977500 if call.ID != "" {
74987501 i.mu.Lock()
74997502 if tc, ok := i.toolCalls[call.ID]; ok {
75007503 tc.Args = call.Summary
75017504 tc.Preview = call.Content
7505+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
75027506 }
75037507 i.mu.Unlock()
7504- if i.btwDialog != nil && i.btwDialog.Active() {
7505- returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
7506- }
75077508 }
75087509 i.confirmDialog.Enqueue(&confirmRequest{
7509- toolName: call.Name,
7510- preview: call.Summary,
7511- resp: resp,
7512- returnToChild: returnToChild,
7510+ toolName: call.Name,
7511+ preview: call.Summary,
7512+ resp: resp,
75137513 })
75147514 i.invalidate()
75157515 return <-resp
@@ -529,6 +529,7 @@ type Interactive struct {
529 cumUsage provider.Usage529 cumUsage provider.Usage
530 lastCtxInput int // input_tokens of the most recent turn — approximates current context size530 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
531 busy bool531 busy bool
532+ activity agentActivity
532 subagentActivityActive bool533 subagentActivityActive bool
533 pendingIdleWork []func()534 pendingIdleWork []func()
534 dirty chan struct{}535 dirty chan struct{}
@@ -813,6 +814,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
813 clock: time.Now,814 clock: time.Now,
814 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),815 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
815 }816 }
817+ i.btwDialog.setCloseHook(func() {
818+ i.confirmDialog.CancelChildConfirmations("side chat closed")
819+ })
816 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)820 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
817 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)821 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
818 if cfg.LlamaCPPConfig != nil {822 if cfg.LlamaCPPConfig != nil {
@@ -1773,16 +1777,17 @@ func (i *Interactive) redraw() {
1773 suggest = append([]string{""}, suggest...)1777 suggest = append([]string{""}, suggest...)
1774 }1778 }
1775 1779
1776- // Busy prefix shown at the far left of the status bar. The1780+ // Busy prefix shown at the far left of the status bar. The spinner owns
1777- // spinner glyph and its funny-line message share the `zut`1781+ // only its frame; the activity label reports the operation in progress.
1778- // label colour (Theme.Assistant) so the whole "who's working"
1779- // band reads at a glance. Elapsed time stays muted because it
1780- // drifts every second and shouldn't grab focus.
1781 var busyPrefix string1782 var busyPrefix string
1782 if i.busy {1783 if i.busy {
1784+ label := i.activity.label()
1785+ if label == "" {
1786+ label = "Preparing request"
1787+ }
1783 busyPrefix = fmt.Sprintf("%s %s %s %s",1788 busyPrefix = fmt.Sprintf("%s %s %s %s",
1784 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),1789 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1785- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),1790+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
1786 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),1791 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
1787 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),1792 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1788 )1793 )
@@ -5508,7 +5513,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5508 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {5513 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
5509 i.mu.Lock()5514 i.mu.Lock()
5510 i.modelRefreshing = true5515 i.modelRefreshing = true
5511- i.statusOK = "refreshing models"5516+ i.statusOK = "Refreshing models"
5512 i.statusErr = ""5517 i.statusErr = ""
5513 i.mu.Unlock()5518 i.mu.Unlock()
5514 go func() {5519 go func() {
@@ -5707,7 +5712,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
5707 if refreshErr != nil {5712 if refreshErr != nil {
5708 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()5713 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
5709 i.statusOK = ""5714 i.statusOK = ""
5710- } else if i.statusOK == "refreshing models" {5715+ } else if i.statusOK == "Refreshing models" {
5711 i.statusOK = ""5716 i.statusOK = ""
5712 }5717 }
5713 i.mu.Unlock()5718 i.mu.Unlock()
@@ -6533,11 +6538,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6533 i.mu.Lock()6538 i.mu.Lock()
6534 i.busy = true6539 i.busy = true
6535 i.compacting = true6540 i.compacting = true
6541+ i.spin.Start()
6542+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6536 if auto {6543 if auto {
6537- i.spin.StartFixed("condensing history")6544+ i.activity.kind = activityCondensingHistory
6538 i.autoCompacting = true6545 i.autoCompacting = true
6539 } else {6546 } else {
6540- i.spin.StartFixed("compacting")6547+ i.activity.kind = activityCompactingHistory
6541 }6548 }
6542 i.cancelTurn = cancel6549 i.cancelTurn = cancel
6543 i.statusErr = ""6550 i.statusErr = ""
@@ -6733,7 +6740,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6733 i.cancelTurn = cancel6740 i.cancelTurn = cancel
6734 i.statusErr = ""6741 i.statusErr = ""
6735 i.statusOK = ""6742 i.statusOK = ""
6736- i.spin.StartFixed("running shell command")6743+ i.spin.Start()
6744+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
6737 // Clear stale extension notes the same way a new turn would so the6745 // Clear stale extension notes the same way a new turn would so the
6738 // screen doesn't accumulate transient state.6746 // screen doesn't accumulate transient state.
6739 i.extNotes = nil6747 i.extNotes = nil
@@ -6866,6 +6874,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6866 i.mu.Lock()6874 i.mu.Lock()
6867 i.busy = true6875 i.busy = true
6868 i.spin.Start()6876 i.spin.Start()
6877+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6869 i.cancelTurn = cancel6878 i.cancelTurn = cancel
6870 i.statusErr = ""6879 i.statusErr = ""
6871 i.statusOK = ""6880 i.statusOK = ""
@@ -6974,7 +6983,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6974 // based auto-compact can miss both when metadata is stale or the6983 // based auto-compact can miss both when metadata is stale or the
6975 // limit is measured in raw bytes. Compact once, then continue the6984 // limit is measured in raw bytes. Compact once, then continue the
6976 // user message already present in the transcript.6985 // user message already present in the transcript.
6977- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)6986+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
6978 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted6987 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6979 if recoverContextOverflow {6988 if recoverContextOverflow {
6980 i.statusErr = ""6989 i.statusErr = ""
@@ -7148,27 +7157,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
7148 return " " + th.FGColor(th.Warning, "⚠ "+msg)7157 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7149 }7158 }
7150 7159
7151-// isContextOverflowError matches provider responses that reject the
7152-// current request because its raw payload or tokenized input is too
7153-// large. Providers surface this as either HTTP 413 or semantic context
7154-// errors with differing codes and messages.
7155-func isContextOverflowError(err error) bool {
7156- if err == nil {
7157- return false
7158- }
7159- msg := strings.ToLower(err.Error())
7160- return strings.Contains(msg, "http 413") ||
7161- strings.Contains(msg, " 413") ||
7162- strings.HasPrefix(msg, "413 ") ||
7163- strings.Contains(msg, "payload too large") ||
7164- strings.Contains(msg, "request entity too large") ||
7165- strings.Contains(msg, "input exceeds the context window") ||
7166- strings.Contains(msg, "context window exceeded") ||
7167- strings.Contains(msg, "maximum context length") ||
7168- strings.Contains(msg, "context_length_exceeded") ||
7169- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7170-}
7171-
7172 const defaultAutoCompactThreshold = 857160 const defaultAutoCompactThreshold = 85
7173 7161
7174 func normalizeAutoCompactThreshold(threshold *int) int {7162 func normalizeAutoCompactThreshold(threshold *int) int {
@@ -7241,6 +7229,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
7241 func (i *Interactive) handleEvent(ev core.AgentEvent) {7229 func (i *Interactive) handleEvent(ev core.AgentEvent) {
7242 i.mu.Lock()7230 i.mu.Lock()
7243 defer i.mu.Unlock()7231 defer i.mu.Unlock()
7232+ i.activity.apply(ev)
7244 switch e := ev.(type) {7233 switch e := ev.(type) {
7245 case core.EvAssistantStart:7234 case core.EvAssistantStart:
7246 // Fires at the top of every oneTurn, including follow-up7235 // Fires at the top of every oneTurn, including follow-up
@@ -7463,7 +7452,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
7463 return7452 return
7464 }7453 }
7465 i.mu.Lock()7454 i.mu.Lock()
7466- i.statusOK = "reloading extensions..."7455+ i.statusOK = "Reloading extensions..."
7467 i.statusErr = ""7456 i.statusErr = ""
7468 i.mu.Unlock()7457 i.mu.Unlock()
7469 i.invalidate()7458 i.invalidate()
@@ -7493,23 +7482,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis
7493 // tool panel, then blocks until the user approves or refuses the call.7482 // tool panel, then blocks until the user approves or refuses the call.
7494 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {7483 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
7495 resp := make(chan core.ConfirmDecision, 1)7484 resp := make(chan core.ConfirmDecision, 1)
7496- returnToChild := false7485+ if isBtwOrigin(call.Origin) {
7486+ req := &confirmRequest{
7487+ toolName: call.Name,
7488+ preview: call.Summary,
7489+ resp: resp,
7490+ returnToChild: true,
7491+ }
7492+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7493+ i.confirmDialog.Enqueue(req)
7494+ }) {
7495+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7496+ }
7497+ i.invalidate()
7498+ return <-resp
7499+ }
7497 if call.ID != "" {7500 if call.ID != "" {
7498 i.mu.Lock()7501 i.mu.Lock()
7499 if tc, ok := i.toolCalls[call.ID]; ok {7502 if tc, ok := i.toolCalls[call.ID]; ok {
7500 tc.Args = call.Summary7503 tc.Args = call.Summary
7501 tc.Preview = call.Content7504 tc.Preview = call.Content
7505+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
7502 }7506 }
7503 i.mu.Unlock()7507 i.mu.Unlock()
7504- if i.btwDialog != nil && i.btwDialog.Active() {
7505- returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
7506- }
7507 }7508 }
7508 i.confirmDialog.Enqueue(&confirmRequest{7509 i.confirmDialog.Enqueue(&confirmRequest{
7509- toolName: call.Name,7510+ toolName: call.Name,
7510- preview: call.Summary,7511+ preview: call.Summary,
7511- resp: resp,7512+ resp: resp,
7512- returnToChild: returnToChild,
7513 })7513 })
7514 i.invalidate()7514 i.invalidate()
7515 return <-resp7515 return <-resp
M packages/agent/modes/interactive_model_test.go +1 -1
@@ -105,7 +105,7 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
105105 if !i.modelDialog.Active() {
106106 t.Fatal("model picker did not open immediately")
107107 }
108- if i.statusOK == "refreshing models" {
108+ if i.statusOK == "Refreshing models" {
109109 t.Fatal("refresh status shown without llama.cpp router configuration")
110110 }
111111 }
@@ -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 {
4141 switch e := ev.(type) {
4242 case core.EvTurnStart:
4343 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()
4455 case core.EvUserMessage:
4556 m["content"] = ContentToJSON(e.Message.Content)
4657 m["time"] = e.Message.Time
@@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
6374 var args any
6475 _ = json.Unmarshal(e.Args, &args)
6576 m["args"] = args
77+ case core.EvToolExecutionStarted:
78+ m["id"] = e.ID
79+ m["name"] = e.Name
6680 case core.EvToolProgress:
6781 m["id"] = e.ID
6882 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.Step43 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.Time57 m["time"] = e.Message.Time
@@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
63 var args any74 var args any
64 _ = json.Unmarshal(e.Args, &args)75 _ = json.Unmarshal(e.Args, &args)
65 m["args"] = args76 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.ID81 m["id"] = e.ID
68 m["text"] = e.Text82 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) {
178178 d.mu.Lock()
179179 client := d.client
180180 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 {
182182 _, err := client.LoadAndWait(ctx, model, update)
183183 return err
184184 })
@@ -178,7 +178,7 @@ func (d *llamaDialog) load(model string) {
178 d.mu.Lock()178 d.mu.Lock()
179 client := d.client179 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 err183 return err
184 })184 })
M packages/agent/modes/rescue_dialog.go +1 -1
@@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) {
233233
234234 // Don't trigger on context overflow; that path already has its own
235235 // auto-compact handling.
236- if isContextOverflowError(err) {
236+ if provider.IsContextOverflowError(err) {
237237 return false, ""
238238 }
239239
@@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) {
233 233
234 // Don't trigger on context overflow; that path already has its own234 // Don't trigger on context overflow; that path already has its own
235 // auto-compact handling.235 // auto-compact handling.
236- if isContextOverflowError(err) {236+ if provider.IsContextOverflowError(err) {
237 return false, ""237 return false, ""
238 }238 }
239 239
M packages/agent/modes/session_dialog.go +2 -2
@@ -514,9 +514,9 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string {
514514 }
515515 idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
516516 }
517- progress := "loading sessions"
517+ progress := "Loading sessions"
518518 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)
520520 }
521521 return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522522 }
@@ -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) {
5252 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
5353 }
5454 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
55- if !strings.Contains(loadingText, "loading sessions") {
55+ if !strings.Contains(loadingText, "Loading sessions") {
5656 t.Fatalf("loading render = %q, want spinner status", loadingText)
5757 }
5858 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -71,7 +71,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
7171 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
7272 }
7373 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
74- if strings.Contains(loadedText, "loading sessions") {
74+ if strings.Contains(loadedText, "Loading sessions") {
7575 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
7676 }
7777 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 @@
11 package modes
22
33 import (
4- "math/rand"
54 "time"
65
76 "github.com/bnema/zut/packages/tui"
87 )
98
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.
1312 type spinner struct {
1413 frames []string
15- messages []string
1614 interval time.Duration
1715 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
2416 }
2517
2618 // newSpinner constructs a fresh spinner.
@@ -35,42 +27,16 @@ func (s *spinner) Configure(th tui.Theme) {
3527 if len(s.frames) == 0 {
3628 s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
3729 }
38- s.messages = append([]string(nil), th.SpinnerMessages...)
39- if len(s.messages) == 0 {
40- s.messages = []string{"thinking"}
41- }
4230 interval := th.SpinnerIntervalMS
4331 if interval <= 0 {
4432 interval = 80
4533 }
4634 s.interval = time.Duration(interval) * time.Millisecond
47- if s.msgIdx >= len(s.messages) {
48- s.msgIdx = 0
49- }
5035 }
5136
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.
6038 func (s *spinner) Start() {
6139 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
7440 }
7541
7642 // Frame returns the current spinner glyph for the running animation.
@@ -112,23 +78,6 @@ func (s *spinner) FrameAt(now time.Time) string {
11278 return s.frames[idx]
11379 }
11480
115-// Message returns the spinner's status text. One random phrase
116-// per Start call, pinned until the next turn. When the spinner
117-// was started via StartFixed, the pinned message is returned
118-// unchanged.
119-func (s *spinner) Message() string {
120- if s.fixedMsg != "" {
121- return s.fixedMsg
122- }
123- if len(s.messages) == 0 {
124- return "thinking"
125- }
126- if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
127- s.msgIdx = 0
128- }
129- return s.messages[s.msgIdx]
130-}
131-
13281 // Elapsed returns the wall-clock duration the spinner has been running.
13382 func (s *spinner) Elapsed() time.Duration {
13483 if s.startedAt.IsZero() {
@@ -1,26 +1,18 @@
1 package modes1 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 a9+// spinner drives the busy animation shown while an operation is active. The
11-// turn is streaming. It rotates through a list of playful status10+// 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 []string13 frames []string
15- messages []string
16 interval time.Duration14 interval time.Duration
17 startedAt time.Time15 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.SpinnerIntervalMS30 interval := th.SpinnerIntervalMS
43 if interval <= 0 {31 if interval <= 0 {
44 interval = 8032 interval = 80
45 }33 }
46 s.interval = time.Duration(interval) * time.Millisecond34 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 and37+// Start resets the animation clock for a new operation.
53-// picks a random message that stays fixed for the whole run. A
54-// rotating rollodex of quips during a single turn felt noisy in
55-// practice — you'd see five different phrases for one
56-// long-running response, which implies progress that isn't
57-// actually happening. One stable phrase per turn reads calmer
58-// and the variety across turns (next Start picks another index)
59-// still keeps the set fresh over a session.
60 func (s *spinner) Start() {38 func (s *spinner) Start() {
61 s.startedAt = time.Now()39 s.startedAt = time.Now()
62- if len(s.messages) == 0 {
63- s.messages = []string{"thinking"}
64- }
65- s.msgIdx = rand.Intn(len(s.messages))
66- s.fixedMsg = ""
67-}
68-
69-// StartFixed is like Start but pins the status text to msg for the
70-// duration of this spinner run. Cleared by the next Start() call.
71-func (s *spinner) StartFixed(msg string) {
72- s.startedAt = time.Now()
73- s.fixedMsg = msg
74 }40 }
75 41
76 // Frame returns the current spinner glyph for the running animation.42 // Frame returns the current spinner glyph for the running animation.
@@ -112,23 +78,6 @@ func (s *spinner) FrameAt(now time.Time) string {
112 return s.frames[idx]78 return s.frames[idx]
113 }79 }
114 80
115-// Message returns the spinner's status text. One random phrase
116-// per Start call, pinned until the next turn. When the spinner
117-// was started via StartFixed, the pinned message is returned
118-// unchanged.
119-func (s *spinner) Message() string {
120- if s.fixedMsg != "" {
121- return s.fixedMsg
122- }
123- if len(s.messages) == 0 {
124- return "thinking"
125- }
126- if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
127- s.msgIdx = 0
128- }
129- return s.messages[s.msgIdx]
130-}
131-
132 // Elapsed returns the wall-clock duration the spinner has been running.81 // Elapsed returns the wall-clock duration the spinner has been running.
133 func (s *spinner) Elapsed() time.Duration {82 func (s *spinner) Elapsed() time.Duration {
134 if s.startedAt.IsZero() {83 if s.startedAt.IsZero() {
M packages/agent/rpc.go +3 -1
@@ -442,7 +442,9 @@ func (s *rpcServer) runCompact(id string) {
442442 defer s.setCancel(nil)
443443
444444 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+ })
446448 if err != nil {
447449 if !errors.Is(err, context.Canceled) {
448450 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 {
1818 Step int `json:"step,omitempty"`
1919 Time string `json:"time,omitempty"`
2020
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+
2130 // text_delta
2231 Delta string `json:"delta,omitempty"`
2332
@@ -131,16 +140,39 @@ func toEvent(ev core.AgentEvent) Event {
131140 switch e := ev.(type) {
132141 case core.EvTurnStart:
133142 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
134155 case core.EvUserMessage:
135156 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
136157 case core.EvAssistantMessage:
137158 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
138159 case core.EvTextDelta:
139160 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
140169 case core.EvToolCall:
141170 out.ID = e.ID
142171 out.Name = e.Name
143172 out.Args = e.Args
173+ case core.EvToolExecutionStarted:
174+ out.ID = e.ID
175+ out.Name = e.Name
144176 case core.EvToolProgress:
145177 out.ID = e.ID
146178 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_delta30 // 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.Step142 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.Delta160 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.ID170 out.ID = e.ID
142 out.Name = e.Name171 out.Name = e.Name
143 out.Args = e.Args172 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.ID177 out.ID = e.ID
146 out.Text = e.Text178 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.
8888 "name": "custom-spinner",
8989 "description": "Only changes the busy spinner.",
9090 "spinner_frames": ["◢", "◣", "◤", "◥"],
91- "spinner_messages": ["working"],
9291 "spinner_interval_ms": 120
9392 }
9493 ```
@@ -103,11 +102,10 @@ settings are used on dark defaults.
103102 ```json
104103 {
105104 "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.",
107106 "colors": {
108107 "dark": {
109108 "spinner_frames": ["◢", "◣", "◤", "◥"],
110- "spinner_messages": ["working"],
111109 "spinner_interval_ms": 120
112110 }
113111 }
@@ -142,7 +140,6 @@ All fields below are optional.
142140 "selection_bg": 24,
143141 "selection_fg": 231,
144142 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
145- "spinner_messages": ["thinking", "working"],
146143 "spinner_interval_ms": 80,
147144 "syntax_base_style": "monokai",
148145 "syntax": {
@@ -183,7 +180,7 @@ under `colors`, when they should apply to both modes:
183180 "name": "tiny",
184181 "accent": 204,
185182 "colors": {
186- "spinner_messages": ["shipping"]
183+ "spinner_frames": ["◐", "◓", "◑", "◒"]
187184 }
188185 }
189186 ```
@@ -230,16 +227,17 @@ Spinner settings can appear at top level, under `colors`, or under
230227
231228 - `spinner_frames` — list of frame strings. Use single-cell glyphs
232229 when possible so status-bar alignment stays clean.
233-- `spinner_messages` — list of messages; zut chooses one per turn.
234230 - `spinner_interval_ms` — frame interval in milliseconds; must be
235231 positive. Missing/invalid falls back to 80ms.
236232
233+The working-status label belongs to zut and always describes the current
234+operation. The legacy `spinner_messages` field is ignored.
235+
237236 Example:
238237
239238 ```json
240239 {
241240 "spinner_frames": ["◐", "◓", "◑", "◒"],
242- "spinner_messages": ["shipping pixels", "warming edge cache"],
243241 "spinner_interval_ms": 120
244242 }
245243 ```
@@ -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": 12091 "spinner_interval_ms": 120
93 }92 }
94 ```93 ```
@@ -103,11 +102,10 @@ settings are used on dark defaults.
103 ```json102 ```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": 120109 "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 glyphs228 - `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 be230 - `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 ```json238 ```json
240 {239 {
241 "spinner_frames": ["◐", "◓", "◑", "◒"],240 "spinner_frames": ["◐", "◓", "◑", "◒"],
242- "spinner_messages": ["shipping pixels", "warming edge cache"],
243 "spinner_interval_ms": 120241 "spinner_interval_ms": 120
244 }242 }
245 ```243 ```
M packages/agent/subagent_worker.go +67 -4
@@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
201201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
202202
203203 sink := func(ev core.AgentEvent) {
204- em.emit(ev.Type(), modes.EventToJSON(ev))
204+ em.emit(ev.Type(), subagentEventData(ev))
205205 }
206206
207- start := len(ag.Messages())
208- err := ag.Prompt(c, prompt, nil, sink)
207+ var persistCompaction func([]provider.Message) error
208+ if sess != nil {
209+ persistCompaction = sess.AppendCompaction
210+ }
211+ start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)
209212 WriteNewTranscript(ag, sess, start)
210213
211214 output := finalAssistantText(ag.Messages()[start:])
@@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
231234 em.emit("turn_end", map[string]any{
232235 "step": step,
233236 "turn_id": turnID,
234- "error": errString(err),
237+ "error": turnErrorMessage(err),
235238 })
236239 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237240
@@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
402405 }
403406 }
404407
408+func subagentEventData(ev core.AgentEvent) map[string]any {
409+ data := modes.EventToJSON(ev)
410+ if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
411+ data["error"] = subagentContextLimitMessage
412+ }
413+ return data
414+}
415+
405416 func canonicalWorkerEvent(typ string) string {
406417 switch typ {
407418 case "agent_ready":
@@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string {
423434 }
424435 }
425436
437+const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438+
439+// promptWithContextRecovery appends prompt and runs it once. When the provider
440+// rejects that request for exceeding its context limit, it compacts the current
441+// transcript and continues the already-appended prompt exactly once.
442+//
443+// The returned index marks the first message that can contribute to the turn
444+// result or be appended after a compaction checkpoint. Compact replaces the
445+// transcript, so callers must not reuse the index captured before Prompt.
446+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447+ outputStart = len(ag.Messages())
448+ if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
449+ return outputStart, err
450+ }
451+
452+ messages := ag.Messages()
453+ if len(messages) <= 1 {
454+ // The only message is the failed prompt. Summarizing it would replace
455+ // the user's task with an LLM-generated paraphrase, so report the
456+ // provider's limit and ask the caller to narrow the task instead.
457+ return outputStart, err
458+ }
459+ keepTail := min(4, len(messages)-1)
460+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461+ return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462+ }
463+
464+ compacted := ag.Messages()
465+ if persistCompaction != nil {
466+ if persistErr := persistCompaction(compacted); persistErr != nil {
467+ // Keep the original history plus the user's pending task so the
468+ // outer transcript flush can persist that task without committing
469+ // an in-memory compaction that the session did not receive.
470+ ag.SetMessages(messages)
471+ return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472+ }
473+ }
474+
475+ outputStart = len(compacted)
476+ return outputStart, ag.Continue(ctx, sink)
477+}
478+
426479 func finalAssistantText(messages []provider.Message) string {
427480 var out strings.Builder
428481 for _, message := range messages {
@@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any {
498551 if err == nil {
499552 return nil
500553 }
554+ if provider.IsContextOverflowError(err) {
555+ return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
556+ }
501557 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
502558 }
503559
560+func turnErrorMessage(err error) string {
561+ if provider.IsContextOverflowError(err) {
562+ return subagentContextLimitMessage
563+ }
564+ return errString(err)
565+}
566+
504567 func errString(err error) string {
505568 if err == nil {
506569 return ""
@@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
202 202
203 sink := func(ev core.AgentEvent) {203 sink := func(ev core.AgentEvent) {
204- em.emit(ev.Type(), modes.EventToJSON(ev))204+ em.emit(ev.Type(), subagentEventData(ev))
205 }205 }
206 206
207- start := len(ag.Messages())207+ var persistCompaction func([]provider.Message) error
208- err := ag.Prompt(c, prompt, nil, sink)208+ if sess != nil {
209+ persistCompaction = sess.AppendCompaction
210+ }
211+ start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)
209 WriteNewTranscript(ag, sess, start)212 WriteNewTranscript(ag, sess, start)
210 213
211 output := finalAssistantText(ag.Messages()[start:])214 output := finalAssistantText(ag.Messages()[start:])
@@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
231 em.emit("turn_end", map[string]any{234 em.emit("turn_end", map[string]any{
232 "step": step,235 "step": step,
233 "turn_id": turnID,236 "turn_id": turnID,
234- "error": errString(err),237+ "error": turnErrorMessage(err),
235 })238 })
236 em.emit("agent.idle", map[string]any{"turn_id": turnID})239 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237 240
@@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
402 }405 }
403 }406 }
404 407
408+func subagentEventData(ev core.AgentEvent) map[string]any {
409+ data := modes.EventToJSON(ev)
410+ if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
411+ data["error"] = subagentContextLimitMessage
412+ }
413+ return data
414+}
415+
405 func canonicalWorkerEvent(typ string) string {416 func canonicalWorkerEvent(typ string) string {
406 switch typ {417 switch typ {
407 case "agent_ready":418 case "agent_ready":
@@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string {
423 }434 }
424 }435 }
425 436
437+const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438+
439+// promptWithContextRecovery appends prompt and runs it once. When the provider
440+// rejects that request for exceeding its context limit, it compacts the current
441+// transcript and continues the already-appended prompt exactly once.
442+//
443+// The returned index marks the first message that can contribute to the turn
444+// result or be appended after a compaction checkpoint. Compact replaces the
445+// transcript, so callers must not reuse the index captured before Prompt.
446+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447+ outputStart = len(ag.Messages())
448+ if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
449+ return outputStart, err
450+ }
451+
452+ messages := ag.Messages()
453+ if len(messages) <= 1 {
454+ // The only message is the failed prompt. Summarizing it would replace
455+ // the user's task with an LLM-generated paraphrase, so report the
456+ // provider's limit and ask the caller to narrow the task instead.
457+ return outputStart, err
458+ }
459+ keepTail := min(4, len(messages)-1)
460+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461+ return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462+ }
463+
464+ compacted := ag.Messages()
465+ if persistCompaction != nil {
466+ if persistErr := persistCompaction(compacted); persistErr != nil {
467+ // Keep the original history plus the user's pending task so the
468+ // outer transcript flush can persist that task without committing
469+ // an in-memory compaction that the session did not receive.
470+ ag.SetMessages(messages)
471+ return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472+ }
473+ }
474+
475+ outputStart = len(compacted)
476+ return outputStart, ag.Continue(ctx, sink)
477+}
478+
426 func finalAssistantText(messages []provider.Message) string {479 func finalAssistantText(messages []provider.Message) string {
427 var out strings.Builder480 var out strings.Builder
428 for _, message := range messages {481 for _, message := range messages {
@@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any {
498 if err == nil {551 if err == nil {
499 return nil552 return nil
500 }553 }
554+ if provider.IsContextOverflowError(err) {
555+ return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
556+ }
501 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}557 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
502 }558 }
503 559
560+func turnErrorMessage(err error) string {
561+ if provider.IsContextOverflowError(err) {
562+ return subagentContextLimitMessage
563+ }
564+ return errString(err)
565+}
566+
504 func errString(err error) string {567 func errString(err error) string {
505 if err == nil {568 if err == nil {
506 return ""569 return ""
M packages/agent/subagent_worker_test.go +340 -0
@@ -2,14 +2,19 @@ package agent
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
7+ "errors"
68 "io"
79 "os"
810 "path/filepath"
911 "strings"
12+ "sync"
1013 "testing"
1114
1215 "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
1318 )
1419
1520 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
@@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
3035 }
3136 }
3237
38+type subagentContextRecoveryClient struct {
39+ mu sync.Mutex
40+ calls int
41+ requests []provider.Request
42+ retried chan provider.Request
43+ compactionErr error
44+ retryOverflow bool
45+}
46+
47+func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" }
48+
49+func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
50+ c.mu.Lock()
51+ c.calls++
52+ call := c.calls
53+ c.requests = append(c.requests, req)
54+ c.mu.Unlock()
55+
56+ events := make(chan provider.Event, 2)
57+ go func() {
58+ defer close(events)
59+ switch call {
60+ case 1:
61+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
62+ case 2:
63+ if c.compactionErr != nil {
64+ events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr}
65+ return
66+ }
67+ events <- provider.EventTextDelta{Delta: "summary"}
68+ events <- provider.EventDone{Stop: provider.StopEnd}
69+ default:
70+ c.retried <- req
71+ if c.retryOverflow {
72+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
73+ return
74+ }
75+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
76+ Role: provider.RoleAssistant,
77+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
78+ }}
79+ }
80+ }()
81+ return events, nil
82+}
83+
84+func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86+ agent := core.NewAgent(client, "test-model", "", nil)
87+ agent.SetMessages([]provider.Message{
88+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
89+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
90+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
91+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92+ })
93+
94+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
95+ if err != nil {
96+ t.Fatalf("promptWithContextRecovery returned %v", err)
97+ }
98+ if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {
99+ t.Fatalf("final assistant text = %q, want recovered", got)
100+ }
101+
102+ request := <-client.retried
103+ if got := workerRequestUserTextCount(request, "finish the task"); got != 1 {
104+ t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages)
105+ }
106+ client.mu.Lock()
107+ calls := client.calls
108+ requests := append([]provider.Request(nil), client.requests...)
109+ client.mu.Unlock()
110+ if calls != 3 || len(requests) != 3 {
111+ t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests))
112+ }
113+ if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") {
114+ t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages)
115+ }
116+}
117+
118+func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
119+ client := &subagentContextRecoveryClient{
120+ retried: make(chan provider.Request, 1),
121+ retryOverflow: true,
122+ }
123+ agent := core.NewAgent(client, "test-model", "", nil)
124+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125+
126+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
127+ if !provider.IsContextOverflowError(err) {
128+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129+ }
130+ <-client.retried
131+ client.mu.Lock()
132+ calls := client.calls
133+ client.mu.Unlock()
134+ if calls != 3 {
135+ t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls)
136+ }
137+}
138+
139+func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) {
140+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
141+ agent := core.NewAgent(client, "test-model", "", nil)
142+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+
146+ _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) {
147+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148+ cancel()
149+ }
150+ }, nil)
151+ if !provider.IsContextOverflowError(err) {
152+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153+ }
154+ if err := ctx.Err(); !errors.Is(err, context.Canceled) {
155+ t.Fatalf("context error = %v, want %v", err, context.Canceled)
156+ }
157+ client.mu.Lock()
158+ calls := client.calls
159+ client.mu.Unlock()
160+ if calls != 1 {
161+ t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls)
162+ }
163+}
164+
165+func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
166+ client := &subagentContextRecoveryClient{
167+ retried: make(chan provider.Request, 1),
168+ compactionErr: errors.New("compaction provider unavailable"),
169+ }
170+ agent := core.NewAgent(client, "test-model", "", nil)
171+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172+
173+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
174+ if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175+ t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176+ }
177+ payload := resultErrorPayload(err)
178+ if payload["code"] != "turn_failed" {
179+ t.Fatalf("result error payload = %#v, want turn_failed", payload)
180+ }
181+}
182+
183+func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185+ agent := core.NewAgent(client, "test-model", "", nil)
186+
187+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
188+ if !provider.IsContextOverflowError(err) {
189+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190+ }
191+ client.mu.Lock()
192+ calls := client.calls
193+ requests := append([]provider.Request(nil), client.requests...)
194+ client.mu.Unlock()
195+ if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 {
196+ t.Fatalf("initial request does not retain the prompt: %#v", requests)
197+ }
198+ if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
199+ t.Fatalf("single prompt was not retained: %#v", agent.Messages())
200+ }
201+ if calls != 1 {
202+ t.Fatalf("provider calls = %d, want 1 without compaction", calls)
203+ }
204+}
205+
206+func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) {
207+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
208+ history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}
209+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
210+ if err != nil {
211+ t.Fatalf("create session: %v", err)
212+ }
213+ if err := session.AppendMessage(history[0]); err != nil {
214+ t.Fatalf("append history: %v", err)
215+ }
216+ agent := core.NewAgent(client, "test-model", "", nil)
217+ agent.SetMessages(history)
218+
219+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {
220+ return errors.New("persist failed")
221+ })
222+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223+ t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
224+ }
225+ if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226+ t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227+ }
228+ WriteNewTranscript(agent, session, outputStart)
229+ if err := session.Close(); err != nil {
230+ t.Fatalf("close session: %v", err)
231+ }
232+ reopened, messages, err := core.OpenSession(session.Path)
233+ if err != nil {
234+ t.Fatalf("reopen session: %v", err)
235+ }
236+ t.Cleanup(func() {
237+ if err := reopened.Close(); err != nil {
238+ t.Errorf("close reopened session: %v", err)
239+ }
240+ })
241+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
242+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
243+ }
244+ client.mu.Lock()
245+ calls := client.calls
246+ client.mu.Unlock()
247+ if calls != 2 {
248+ t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls)
249+ }
250+}
251+
252+func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
253+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
254+ history := []provider.Message{
255+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
256+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
257+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
258+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
259+ }
260+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
261+ if err != nil {
262+ t.Fatalf("create session: %v", err)
263+ }
264+ for _, message := range history {
265+ if err := session.AppendMessage(message); err != nil {
266+ t.Fatalf("append history: %v", err)
267+ }
268+ }
269+
270+ agent := core.NewAgent(client, "test-model", "", nil)
271+ agent.SetMessages(history)
272+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)
273+ if err != nil {
274+ t.Fatalf("promptWithContextRecovery returned %v", err)
275+ }
276+ WriteNewTranscript(agent, session, outputStart)
277+ if err := session.Close(); err != nil {
278+ t.Fatalf("close session: %v", err)
279+ }
280+
281+ records, err := os.ReadFile(session.Path)
282+ if err != nil {
283+ t.Fatalf("read session: %v", err)
284+ }
285+ compactions := 0
286+ for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) {
287+ var entry struct {
288+ Type string `json:"type"`
289+ }
290+ if err := json.Unmarshal(record, &entry); err != nil {
291+ t.Fatalf("decode session record: %v", err)
292+ }
293+ if entry.Type == "compaction" {
294+ compactions++
295+ }
296+ }
297+ if compactions != 1 {
298+ t.Fatalf("compaction records = %d, want 1", compactions)
299+ }
300+
301+ reopened, messages, err := core.OpenSession(session.Path)
302+ if err != nil {
303+ t.Fatalf("reopen session: %v", err)
304+ }
305+ t.Cleanup(func() {
306+ if err := reopened.Close(); err != nil {
307+ t.Errorf("close reopened session: %v", err)
308+ }
309+ })
310+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
311+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
312+ }
313+ if got := finalAssistantText(messages); got != "two\nfour\nrecovered" {
314+ t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got)
315+ }
316+}
317+
318+func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319+ count := 0
320+ for _, message := range messages {
321+ for _, block := range message.Content {
322+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
323+ count++
324+ }
325+ }
326+ }
327+ return count
328+}
329+
330+func workerRequestContainsText(req provider.Request, want string) bool {
331+ for _, message := range req.Messages {
332+ for _, block := range message.Content {
333+ if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
334+ return true
335+ }
336+ }
337+ }
338+ return false
339+}
340+
341+func workerRequestUserTextCount(req provider.Request, want string) int {
342+ count := 0
343+ for _, message := range req.Messages {
344+ if message.Role != provider.RoleUser {
345+ continue
346+ }
347+ for _, block := range message.Content {
348+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
349+ count++
350+ }
351+ }
352+ }
353+ return count
354+}
355+
356+func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
357+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))
358+ if payload["code"] != "context_limit" {
359+ t.Fatalf("error code = %q, want context_limit", payload["code"])
360+ }
361+ if payload["message"] != subagentContextLimitMessage {
362+ t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage)
363+ }
364+}
365+
366+func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
367+ payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")})
368+ if payload["error"] != subagentContextLimitMessage {
369+ t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage)
370+ }
371+}
372+
33373 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
34374 // "everything is doubled after reopening a subagent agent" bug.
35375 //
@@ -2,14 +2,19 @@ package agent
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
6 "io"8 "io"
7 "os"9 "os"
8 "path/filepath"10 "path/filepath"
9 "strings"11 "strings"
12+ "sync"
10 "testing"13 "testing"
11 14
12 "github.com/bnema/zut/packages/agent/subagents"15 "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
13 )18 )
14 19
15 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads20 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
@@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
30 }35 }
31 }36 }
32 37
38+type subagentContextRecoveryClient struct {
39+ mu sync.Mutex
40+ calls int
41+ requests []provider.Request
42+ retried chan provider.Request
43+ compactionErr error
44+ retryOverflow bool
45+}
46+
47+func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" }
48+
49+func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
50+ c.mu.Lock()
51+ c.calls++
52+ call := c.calls
53+ c.requests = append(c.requests, req)
54+ c.mu.Unlock()
55+
56+ events := make(chan provider.Event, 2)
57+ go func() {
58+ defer close(events)
59+ switch call {
60+ case 1:
61+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
62+ case 2:
63+ if c.compactionErr != nil {
64+ events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr}
65+ return
66+ }
67+ events <- provider.EventTextDelta{Delta: "summary"}
68+ events <- provider.EventDone{Stop: provider.StopEnd}
69+ default:
70+ c.retried <- req
71+ if c.retryOverflow {
72+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
73+ return
74+ }
75+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
76+ Role: provider.RoleAssistant,
77+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
78+ }}
79+ }
80+ }()
81+ return events, nil
82+}
83+
84+func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86+ agent := core.NewAgent(client, "test-model", "", nil)
87+ agent.SetMessages([]provider.Message{
88+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
89+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
90+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
91+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92+ })
93+
94+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
95+ if err != nil {
96+ t.Fatalf("promptWithContextRecovery returned %v", err)
97+ }
98+ if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {
99+ t.Fatalf("final assistant text = %q, want recovered", got)
100+ }
101+
102+ request := <-client.retried
103+ if got := workerRequestUserTextCount(request, "finish the task"); got != 1 {
104+ t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages)
105+ }
106+ client.mu.Lock()
107+ calls := client.calls
108+ requests := append([]provider.Request(nil), client.requests...)
109+ client.mu.Unlock()
110+ if calls != 3 || len(requests) != 3 {
111+ t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests))
112+ }
113+ if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") {
114+ t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages)
115+ }
116+}
117+
118+func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
119+ client := &subagentContextRecoveryClient{
120+ retried: make(chan provider.Request, 1),
121+ retryOverflow: true,
122+ }
123+ agent := core.NewAgent(client, "test-model", "", nil)
124+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125+
126+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
127+ if !provider.IsContextOverflowError(err) {
128+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129+ }
130+ <-client.retried
131+ client.mu.Lock()
132+ calls := client.calls
133+ client.mu.Unlock()
134+ if calls != 3 {
135+ t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls)
136+ }
137+}
138+
139+func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) {
140+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
141+ agent := core.NewAgent(client, "test-model", "", nil)
142+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+
146+ _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) {
147+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148+ cancel()
149+ }
150+ }, nil)
151+ if !provider.IsContextOverflowError(err) {
152+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153+ }
154+ if err := ctx.Err(); !errors.Is(err, context.Canceled) {
155+ t.Fatalf("context error = %v, want %v", err, context.Canceled)
156+ }
157+ client.mu.Lock()
158+ calls := client.calls
159+ client.mu.Unlock()
160+ if calls != 1 {
161+ t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls)
162+ }
163+}
164+
165+func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
166+ client := &subagentContextRecoveryClient{
167+ retried: make(chan provider.Request, 1),
168+ compactionErr: errors.New("compaction provider unavailable"),
169+ }
170+ agent := core.NewAgent(client, "test-model", "", nil)
171+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172+
173+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
174+ if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175+ t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176+ }
177+ payload := resultErrorPayload(err)
178+ if payload["code"] != "turn_failed" {
179+ t.Fatalf("result error payload = %#v, want turn_failed", payload)
180+ }
181+}
182+
183+func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185+ agent := core.NewAgent(client, "test-model", "", nil)
186+
187+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
188+ if !provider.IsContextOverflowError(err) {
189+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190+ }
191+ client.mu.Lock()
192+ calls := client.calls
193+ requests := append([]provider.Request(nil), client.requests...)
194+ client.mu.Unlock()
195+ if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 {
196+ t.Fatalf("initial request does not retain the prompt: %#v", requests)
197+ }
198+ if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
199+ t.Fatalf("single prompt was not retained: %#v", agent.Messages())
200+ }
201+ if calls != 1 {
202+ t.Fatalf("provider calls = %d, want 1 without compaction", calls)
203+ }
204+}
205+
206+func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) {
207+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
208+ history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}
209+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
210+ if err != nil {
211+ t.Fatalf("create session: %v", err)
212+ }
213+ if err := session.AppendMessage(history[0]); err != nil {
214+ t.Fatalf("append history: %v", err)
215+ }
216+ agent := core.NewAgent(client, "test-model", "", nil)
217+ agent.SetMessages(history)
218+
219+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {
220+ return errors.New("persist failed")
221+ })
222+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223+ t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
224+ }
225+ if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226+ t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227+ }
228+ WriteNewTranscript(agent, session, outputStart)
229+ if err := session.Close(); err != nil {
230+ t.Fatalf("close session: %v", err)
231+ }
232+ reopened, messages, err := core.OpenSession(session.Path)
233+ if err != nil {
234+ t.Fatalf("reopen session: %v", err)
235+ }
236+ t.Cleanup(func() {
237+ if err := reopened.Close(); err != nil {
238+ t.Errorf("close reopened session: %v", err)
239+ }
240+ })
241+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
242+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
243+ }
244+ client.mu.Lock()
245+ calls := client.calls
246+ client.mu.Unlock()
247+ if calls != 2 {
248+ t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls)
249+ }
250+}
251+
252+func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
253+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
254+ history := []provider.Message{
255+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
256+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
257+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
258+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
259+ }
260+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
261+ if err != nil {
262+ t.Fatalf("create session: %v", err)
263+ }
264+ for _, message := range history {
265+ if err := session.AppendMessage(message); err != nil {
266+ t.Fatalf("append history: %v", err)
267+ }
268+ }
269+
270+ agent := core.NewAgent(client, "test-model", "", nil)
271+ agent.SetMessages(history)
272+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)
273+ if err != nil {
274+ t.Fatalf("promptWithContextRecovery returned %v", err)
275+ }
276+ WriteNewTranscript(agent, session, outputStart)
277+ if err := session.Close(); err != nil {
278+ t.Fatalf("close session: %v", err)
279+ }
280+
281+ records, err := os.ReadFile(session.Path)
282+ if err != nil {
283+ t.Fatalf("read session: %v", err)
284+ }
285+ compactions := 0
286+ for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) {
287+ var entry struct {
288+ Type string `json:"type"`
289+ }
290+ if err := json.Unmarshal(record, &entry); err != nil {
291+ t.Fatalf("decode session record: %v", err)
292+ }
293+ if entry.Type == "compaction" {
294+ compactions++
295+ }
296+ }
297+ if compactions != 1 {
298+ t.Fatalf("compaction records = %d, want 1", compactions)
299+ }
300+
301+ reopened, messages, err := core.OpenSession(session.Path)
302+ if err != nil {
303+ t.Fatalf("reopen session: %v", err)
304+ }
305+ t.Cleanup(func() {
306+ if err := reopened.Close(); err != nil {
307+ t.Errorf("close reopened session: %v", err)
308+ }
309+ })
310+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
311+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
312+ }
313+ if got := finalAssistantText(messages); got != "two\nfour\nrecovered" {
314+ t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got)
315+ }
316+}
317+
318+func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319+ count := 0
320+ for _, message := range messages {
321+ for _, block := range message.Content {
322+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
323+ count++
324+ }
325+ }
326+ }
327+ return count
328+}
329+
330+func workerRequestContainsText(req provider.Request, want string) bool {
331+ for _, message := range req.Messages {
332+ for _, block := range message.Content {
333+ if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
334+ return true
335+ }
336+ }
337+ }
338+ return false
339+}
340+
341+func workerRequestUserTextCount(req provider.Request, want string) int {
342+ count := 0
343+ for _, message := range req.Messages {
344+ if message.Role != provider.RoleUser {
345+ continue
346+ }
347+ for _, block := range message.Content {
348+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
349+ count++
350+ }
351+ }
352+ }
353+ return count
354+}
355+
356+func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
357+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))
358+ if payload["code"] != "context_limit" {
359+ t.Fatalf("error code = %q, want context_limit", payload["code"])
360+ }
361+ if payload["message"] != subagentContextLimitMessage {
362+ t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage)
363+ }
364+}
365+
366+func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
367+ payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")})
368+ if payload["error"] != subagentContextLimitMessage {
369+ t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage)
370+ }
371+}
372+
33 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the373 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
34 // "everything is doubled after reopening a subagent agent" bug.374 // "everything is doubled after reopening a subagent agent" bug.
35 //375 //
M packages/core/agent.go +55 -4
@@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
474474 assistantMsg provider.Message
475475 err error
476476 )
477+ maxAttempts := max(a.MaxRetries+1, 1)
477478 for attempt := 0; ; attempt++ {
478- stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext)
479+ stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext, attempt, maxAttempts)
479480 sink(EvTurnEnd{Stop: stop, Err: err})
480481 if err == nil || !a.canRetryError(err, attempt) {
481482 break
482483 }
483484 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 {
485496 return sleepErr
486497 }
487498 }
@@ -633,9 +644,36 @@ func (a *Agent) dropLastAssistantMessage() {
633644 }
634645 }
635646
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+
636674 // oneTurn calls the LLM once, forwards events, returns the stop reason
637675 // 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) {
639677 fastMode := a.FastModeEnabled()
640678 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
641679 return provider.StopError, provider.Message{}, err
@@ -664,7 +702,19 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
664702 FastMode: fastMode,
665703 MaxTokens: a.MaxTokens,
666704 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+ })
668718 stream, err := a.Client.Stream(ctx, req)
669719 if err != nil {
670720 return provider.StopError, provider.Message{}, err
@@ -852,6 +902,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
852902 }
853903 }
854904 }()
905+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
855906 out, err := tool.Execute(ctx, args, func(text string) {
856907 sink(EvToolProgress{ID: tc.ID, Text: text})
857908 })
@@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
474 assistantMsg provider.Message474 assistantMsg provider.Message
475 err error475 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 break482 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 sleepErr496 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 reason674 // 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{}, err679 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{}, err720 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 {
2020 func (c *retryFakeClient) Name() string { return "retry-fake" }
2121
2222 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+ }
2326 call := atomic.AddInt32(&c.calls, 1)
2427 out := make(chan provider.Event, 4)
2528 go func() {
@@ -67,6 +70,35 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) {
6770 }
6871 }
6972
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+
70102 // codexRetryFakeClient reproduces a transient OpenAI Codex backend
71103 // failure on the first call and succeeds afterwards.
72104 type codexRetryFakeClient struct {
@@ -288,3 +320,37 @@ func TestAgentPropagatesTemperature(t *testing.T) {
288320 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
289321 }
290322 }
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 backend102 // 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 (
1818 // the summary. 0 means summarize everything; a typical useful value is
1919 // 4-8 (last couple of exchanges).
2020 //
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.
2423 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) {
2536 a.mu.Lock()
2637 msgs := append([]provider.Message(nil), a.messages...)
2738 a.mu.Unlock()
@@ -65,19 +76,39 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
6576 },
6677 },
6778 }
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+ }
6893
6994 stream, err := a.Client.Stream(ctx, req)
7095 if err != nil {
7196 return "", err
7297 }
98+ if eventSink != nil {
99+ eventSink(EvAssistantStart{})
100+ }
73101
74102 var sb strings.Builder
75103 for ev := range stream {
76104 switch e := ev.(type) {
77105 case provider.EventTextDelta:
78106 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})
81112 }
82113 case provider.EventDone:
83114 if e.Err != nil {
@@ -18,10 +18,21 @@ import (
18 // the summary. 0 means summarize everything; a typical useful value is18 // 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. Emitted21+// The method blocks until the summary request completes. The optional sink
22-// events via sink are limited to text deltas from the summary call so22+// 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 "", err96 return "", err
72 }97 }
98+ if eventSink != nil {
99+ eventSink(EvAssistantStart{})
100+ }
73 101
74 var sb strings.Builder102 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 {
4444 Name string
4545 Summary string
4646 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
4750 }
4851
4952 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
@@ -44,6 +44,9 @@ type ToolCallConfirmation struct {
44 Name string44 Name string
45 Summary string45 Summary string
46 Content string46 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 can52 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
M packages/core/events.go +39 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "encoding/json"
5+ "time"
56
67 "github.com/bnema/zut/packages/provider"
78 )
@@ -29,6 +30,35 @@ type EvAssistantStart struct{}
2930
3031 func (EvAssistantStart) Type() string { return "assistant_start" }
3132
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+
3262 type EvTextDelta struct {
3363 Delta string
3464 }
@@ -43,6 +73,15 @@ type EvToolCall struct {
4373
4474 func (EvToolCall) Type() string { return "tool_call" }
4575
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+
4685 // EvToolUseStart fires the moment the provider announces a new
4786 // tool_use block during streaming, before any arg JSON has
4887 // 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 string63 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 new85 // EvToolUseStart fires the moment the provider announces a new
47 // tool_use block during streaming, before any arg JSON has86 // tool_use block during streaming, before any arg JSON has
48 // arrived. Gives UIs a hook to pre-render a live "tool is being87 // 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) {
7878 }
7979 }
8080
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+
81101 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
82102 // the interceptor returns an error ToolResult with the reason text.
83103 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 from101 // 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,
600600 } else {
601601 return nil, fmt.Errorf("bedrock: no auth configured")
602602 }
603+ if err := ctx.Err(); err != nil {
604+ return nil, err
605+ }
606+ if req.Lifecycle != nil {
607+ req.Lifecycle.RequestAttempt(1, 1)
608+ }
603609 resp, err := c.http.Do(httpReq)
604610 if err != nil {
605611 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
578578 return httpReq, nil
579579 }
580580
581- resp, err := doStreamWithRetry(ctx, c.http, newReq)
581+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
582582 if err != nil {
583583 return nil, fmt.Errorf("anthropic: %w", err)
584584 }
@@ -578,7 +578,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
578 return httpReq, nil578 return httpReq, nil
579 }579 }
580 580
581- resp, err := doStreamWithRetry(ctx, c.http, newReq)581+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
582 if err != nil {582 if err != nil {
583 return nil, fmt.Errorf("anthropic: %w", err)583 return nil, fmt.Errorf("anthropic: %w", err)
584 }584 }
A packages/provider/context_overflow.go +42 -0
@@ -0,0 +1,42 @@
1+package provider
2+
3+import "strings"
4+
5+// IsContextOverflowError reports whether err indicates that the provider
6+// rejected a request because its payload or tokenized input exceeds the
7+// serving model's context limit. Provider responses use different HTTP status
8+// codes, error codes, and prose, so callers use this semantic classification
9+// when deciding whether compaction is a safe recovery attempt.
10+func IsContextOverflowError(err error) bool {
11+ if err == nil {
12+ return false
13+ }
14+ msg := strings.ToLower(err.Error())
15+ return hasHTTPStatus(msg, "413") ||
16+ strings.Contains(msg, "payload too large") ||
17+ strings.Contains(msg, "request entity too large") ||
18+ strings.Contains(msg, "input exceeds the context window") ||
19+ strings.Contains(msg, "context window exceeded") ||
20+ strings.Contains(msg, "maximum context length") ||
21+ strings.Contains(msg, "context_length_exceeded") ||
22+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
23+}
24+
25+func hasHTTPStatus(message, code string) bool {
26+ fields := strings.Fields(message)
27+ for i, field := range fields {
28+ if strings.Trim(field, "():,;[]{}") != code {
29+ continue
30+ }
31+ if i == 0 {
32+ return true
33+ }
34+ for offset := 1; offset <= 2 && i-offset >= 0; offset++ {
35+ prefix := strings.Trim(fields[i-offset], "():,;[]{}")
36+ if prefix == "http" || prefix == "status" || prefix == "response" {
37+ return true
38+ }
39+ }
40+ }
41+ return false
42+}
@@ -0,0 +1,42 @@
1+package provider
2+
3+import "strings"
4+
5+// IsContextOverflowError reports whether err indicates that the provider
6+// rejected a request because its payload or tokenized input exceeds the
7+// serving model's context limit. Provider responses use different HTTP status
8+// codes, error codes, and prose, so callers use this semantic classification
9+// when deciding whether compaction is a safe recovery attempt.
10+func IsContextOverflowError(err error) bool {
11+ if err == nil {
12+ return false
13+ }
14+ msg := strings.ToLower(err.Error())
15+ return hasHTTPStatus(msg, "413") ||
16+ strings.Contains(msg, "payload too large") ||
17+ strings.Contains(msg, "request entity too large") ||
18+ strings.Contains(msg, "input exceeds the context window") ||
19+ strings.Contains(msg, "context window exceeded") ||
20+ strings.Contains(msg, "maximum context length") ||
21+ strings.Contains(msg, "context_length_exceeded") ||
22+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
23+}
24+
25+func hasHTTPStatus(message, code string) bool {
26+ fields := strings.Fields(message)
27+ for i, field := range fields {
28+ if strings.Trim(field, "():,;[]{}") != code {
29+ continue
30+ }
31+ if i == 0 {
32+ return true
33+ }
34+ for offset := 1; offset <= 2 && i-offset >= 0; offset++ {
35+ prefix := strings.Trim(fields[i-offset], "():,;[]{}")
36+ if prefix == "http" || prefix == "status" || prefix == "response" {
37+ return true
38+ }
39+ }
40+ }
41+ return false
42+}
A packages/provider/context_overflow_test.go +37 -0
@@ -0,0 +1,37 @@
1+package provider
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestIsContextOverflowError(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ err error
12+ want bool
13+ }{
14+ {name: "nil error", err: nil, want: false},
15+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
16+ {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true},
17+ {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true},
18+ {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true},
19+ {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true},
20+ {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false},
21+ {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false},
22+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
23+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
24+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
25+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
26+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
27+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ if got := IsContextOverflowError(tt.err); got != tt.want {
33+ t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package provider
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestIsContextOverflowError(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ err error
12+ want bool
13+ }{
14+ {name: "nil error", err: nil, want: false},
15+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
16+ {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true},
17+ {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true},
18+ {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true},
19+ {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true},
20+ {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false},
21+ {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false},
22+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
23+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
24+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
25+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
26+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
27+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ if got := IsContextOverflowError(tt.err); got != tt.want {
33+ t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
34+ }
35+ })
36+ }
37+}
M packages/provider/gemini.go +1 -1
@@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
501501 return httpReq, nil
502502 }
503503
504- resp, err := doStreamWithRetry(ctx, c.http, newReq)
504+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
505505 if err != nil {
506506 return nil, fmt.Errorf("google: %w", err)
507507 }
@@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
501 return httpReq, nil501 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
504504 return httpReq, nil
505505 }
506506
507- resp, err := doStreamWithRetry(ctx, c.http, newReq)
507+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
508508 if err != nil {
509509 return nil, fmt.Errorf("%s: %w", c.Name(), err)
510510 }
@@ -504,7 +504,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
504 return httpReq, nil504 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
418418 return httpReq, nil
419419 }
420420
421- resp, err := doStreamWithRetry(ctx, c.http, newReq)
421+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
422422 if err != nil {
423423 return nil, fmt.Errorf("openai-codex: %w", err)
424424 }
@@ -418,7 +418,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
418 return httpReq, nil418 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 {
5151 Name string `json:"name"`
5252 Arguments json.RawMessage `json:"arguments"`
5353 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:"-"`
5457 }
5558
5659 func (ToolCallBlock) isContent() {}
@@ -245,6 +248,14 @@ type EventDone struct {
245248
246249 func (EventDone) isEvent() {}
247250
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+
248259 // Request is a single LLM call.
249260 type Request struct {
250261 Model string
@@ -260,6 +271,9 @@ type Request struct {
260271 // FastMode requests OpenAI's fast service tier. Providers that do not
261272 // implement the OpenAI service-tier contract reject the request.
262273 FastMode bool
274+ // Lifecycle receives in-memory request-attempt notifications. It is not
275+ // included in any provider request payload.
276+ Lifecycle RequestLifecycle
263277 }
264278
265279 // 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 string261 Model string
@@ -260,6 +271,9 @@ type Request struct {
260 // FastMode requests OpenAI's fast service tier. Providers that do not271 // 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 bool273 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 on279 // 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 {
6363 // If every attempt fails, the last error/response is returned so the
6464 // caller can format a normal error message. On context cancellation
6565 // 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) {
6767 var lastErr error
6868 serverDelay := time.Duration(-1) // <0: use the default fixed backoff
69+ maxAttempts := streamRetryAttempts + 1
6970 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
7071 if attempt > 0 {
7172 delay := streamRetryBackoff(attempt)
@@ -73,16 +74,31 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (
7374 delay = serverDelay
7475 }
7576 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+ }
7683 select {
7784 case <-ctx.Done():
7885 return nil, ctx.Err()
7986 case <-time.After(delay):
8087 }
8188 }
89+ if err := ctx.Err(); err != nil {
90+ return nil, err
91+ }
8292 req, err := newReq()
8393 if err != nil {
8494 return nil, err
8595 }
96+ if err := ctx.Err(); err != nil {
97+ return nil, err
98+ }
99+ if lifecycle != nil {
100+ lifecycle.RequestAttempt(attempt+1, maxAttempts)
101+ }
86102 resp, err := client.Do(req)
87103 if err != nil {
88104 lastErr = err
@@ -63,9 +63,10 @@ func streamRetryBackoff(attempt int) time.Duration {
63 // If every attempt fails, the last error/response is returned so the63 // If every attempt fails, the last error/response is returned so the
64 // caller can format a normal error message. On context cancellation64 // 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 error67 var lastErr error
68 serverDelay := time.Duration(-1) // <0: use the default fixed backoff68 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 = serverDelay74 delay = serverDelay
74 }75 }
75 serverDelay = -176 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, err94 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 = err104 lastErr = err
M packages/provider/retry_test.go +119 -1
@@ -2,6 +2,7 @@ package provider
22
33 import (
44 "context"
5+ "errors"
56 "net/http"
67 "net/http/httptest"
78 "strings"
@@ -38,7 +39,8 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
3839 defer srv.Close()
3940
4041 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})
4244 if err != nil {
4345 t.Fatalf("stream returned error: %v", err)
4446 }
@@ -54,6 +56,12 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
5456 if got := attempts.Load(); got != 3 {
5557 t.Fatalf("attempts=%d want 3", got)
5658 }
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+ }
5765 }
5866
5967 // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when
@@ -208,6 +216,74 @@ func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) {
208216 }
209217 }
210218
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+
211287 func TestRetryAfterDelay(t *testing.T) {
212288 mk := func(kv ...string) http.Header {
213289 h := http.Header{}
@@ -291,3 +367,45 @@ func TestIsTransientConnectError(t *testing.T) {
291367 type stringErr string
292368
293369 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 when67 // 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 string367 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 {
7373 ToolOut TerminalColor
7474 Error TerminalColor
7575 Warning TerminalColor
76- Spinner TerminalColor // spinner + funny working line
76+ Spinner TerminalColor // spinner frame and activity label
7777 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
7878 SelectionBG TerminalColor // background for highlighted rows
7979 SelectionFG TerminalColor // foreground for highlighted rows
@@ -86,7 +86,6 @@ type Theme struct {
8686 Terminal TerminalProfile
8787
8888 SpinnerFrames []string
89- SpinnerMessages []string
9089 SpinnerIntervalMS int
9190
9291 SyntaxBaseStyle string
@@ -151,34 +150,6 @@ var defaultSpinnerFrames = []string{
151150 "⠓",
152151 }
153152
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-
182153 var Dark = Theme{
183154 FG: Color256(253),
184155 Muted: Color256(244),
@@ -196,7 +167,6 @@ var Dark = Theme{
196167 SelectionBG: Color256(24), // deep blue background
197168 SelectionFG: Color256(231), // near-white foreground
198169 SpinnerFrames: defaultSpinnerFrames,
199- SpinnerMessages: defaultSpinnerMessages,
200170 SpinnerIntervalMS: 80,
201171 SyntaxBaseStyle: "monokai",
202172 Syntax: nordSyntax,
@@ -219,7 +189,6 @@ var Light = Theme{
219189 SelectionBG: Color256(153), // light blue
220190 SelectionFG: Color256(232), // near-black
221191 SpinnerFrames: defaultSpinnerFrames,
222- SpinnerMessages: defaultSpinnerMessages,
223192 SpinnerIntervalMS: 80,
224193 SyntaxBaseStyle: "monokai",
225194 Syntax: nordSyntax,
@@ -73,7 +73,7 @@ type Theme struct {
73 ToolOut TerminalColor73 ToolOut TerminalColor
74 Error TerminalColor74 Error TerminalColor
75 Warning TerminalColor75 Warning TerminalColor
76- Spinner TerminalColor // spinner + funny working line76+ Spinner TerminalColor // spinner frame and activity label
77 ThinkingMax TerminalColor // status color for the opt-in max reasoning level77 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
78 SelectionBG TerminalColor // background for highlighted rows78 SelectionBG TerminalColor // background for highlighted rows
79 SelectionFG TerminalColor // foreground for highlighted rows79 SelectionFG TerminalColor // foreground for highlighted rows
@@ -86,7 +86,6 @@ type Theme struct {
86 Terminal TerminalProfile86 Terminal TerminalProfile
87 87
88 SpinnerFrames []string88 SpinnerFrames []string
89- SpinnerMessages []string
90 SpinnerIntervalMS int89 SpinnerIntervalMS int
91 90
92 SyntaxBaseStyle string91 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 background167 SelectionBG: Color256(24), // deep blue background
197 SelectionFG: Color256(231), // near-white foreground168 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 blue189 SelectionBG: Color256(153), // light blue
220 SelectionFG: Color256(232), // near-black190 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 {
3838 return err
3939 }
4040 // 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}.
4242 // Metadata fields are ignored by ThemeOverrides because they do not
4343 // have matching json tags.
4444 _ = json.Unmarshal(data, &a.Overrides)
@@ -97,7 +97,6 @@ type ThemeOverrides struct {
9797 SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
9898 SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
9999 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100- SpinnerMessages []string `json:"spinner_messages,omitempty"`
101100 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
102101 SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
103102 Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
@@ -502,9 +501,6 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
502501 if len(o.SpinnerFrames) > 0 {
503502 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
504503 }
505- if len(o.SpinnerMessages) > 0 {
506- th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
507- }
508504 if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
509505 th.SpinnerIntervalMS = *o.SpinnerIntervalMS
510506 }
@@ -38,7 +38,7 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
38 return err38 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 not42 // 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.SpinnerIntervalMS505 th.SpinnerIntervalMS = *o.SpinnerIntervalMS
510 }506 }
M packages/tui/theme_loader_test.go +8 -11
@@ -36,8 +36,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3636 if th.FG != Dark.FG {
3737 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
3838 }
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")
4141 }
4242 }
4343
@@ -53,7 +53,7 @@ func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
5353 }
5454 }
5555
56-func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
56+func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
5757 home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
5858
5959 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
@@ -63,8 +63,8 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
6363 if got := len(th.SpinnerFrames); got != 2 {
6464 t.Fatalf("spinner frame count = %d, want 2", got)
6565 }
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)
6868 }
6969 if th.Accent != Dark.Accent {
7070 t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
@@ -81,9 +81,6 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
8181 if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
8282 t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
8383 }
84- if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" {
85- t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages)
86- }
8784 if th.SpinnerIntervalMS != 120 {
8885 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
8986 }
@@ -92,7 +89,7 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
9289 }
9390 }
9491
95-func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
92+func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
9693 home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
9794
9895 th, _, err := LoadThemeFromHome(home, "shared", Light)
@@ -102,8 +99,8 @@ func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
10299 if th.Accent != Color256(204) {
103100 t.Fatalf("accent = %#v, want 204", th.Accent)
104101 }
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)
107104 }
108105 if th.FG != Light.FG {
109106 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close