detached 10bb5d0

Merge branch 'main' of https://github.com/patriceckhart/zot

commit
10bb5d0ac66f961767918590b5a3f5d3cac06827
author
patriceckhart · · 2026-07-29 11:21 UTC
merge of
92ac3e3 + 7fe8dbc · diff against the first parent
signature
unsigned
refs
tag: v0.3.22
6 files · +314 -41
M README.md +2 -2
@@ -378,7 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378378
379379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.
381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
382382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
383383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
384384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -395,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
395395
396396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
397397
398-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.
398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
399399
400400 ### `/jail`
401401
@@ -378,7 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378 378
379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -395,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
395 395
396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
397 397
398-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
399 399
400 ### `/jail`400 ### `/jail`
401 401
M packages/agent/modes/compact_queue_test.go +219 -2
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "testing"
78 "time"
@@ -93,16 +94,232 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
9394 }
9495 }
9596
97+func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98+ client := &compactQueueClient{
99+ compactionStarted: make(chan struct{}),
100+ releaseCompaction: make(chan struct{}),
101+ followUpRequest: make(chan provider.Request, 1),
102+ }
103+ agent := core.NewAgent(client, "test-model", "", nil)
104+ agent.SetMessages([]provider.Message{
105+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
106+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
107+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
108+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110+ })
111+ threshold := 70
112+ interactive := NewInteractive(InteractiveConfig{
113+ Agent: agent,
114+ Provider: "anthropic",
115+ Model: "claude-sonnet-4-5",
116+ AutoCompactThreshold: &threshold,
117+ })
118+ interactive.runCtx = context.Background()
119+ interactive.lastCtxInput = 150000
120+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
121+
122+ interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
123+ select {
124+ case <-client.compactionStarted:
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("pre-turn compaction did not start")
127+ }
128+ close(client.releaseCompaction)
129+
130+ select {
131+ case req := <-client.followUpRequest:
132+ if got := requestUserTextCount(req, "follow up"); got != 1 {
133+ t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
134+ }
135+ if got := requestImageCount(req, image); got != 1 {
136+ t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
137+ }
138+ case <-time.After(2 * time.Second):
139+ t.Fatal("pre-turn compaction did not start the pending image request")
140+ }
141+}
142+
143+type contextRecoveryClient struct {
144+ mu sync.Mutex
145+ calls int
146+ retried chan provider.Request
147+ overflowRetry bool
148+}
149+
150+func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
151+
152+func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
153+ c.mu.Lock()
154+ c.calls++
155+ call := c.calls
156+ c.mu.Unlock()
157+
158+ out := make(chan provider.Event, 2)
159+ go func() {
160+ defer close(out)
161+ switch call {
162+ case 1:
163+ out <- provider.EventDone{
164+ Stop: provider.StopError,
165+ Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
166+ }
167+ case 2:
168+ out <- provider.EventTextDelta{Delta: "summary"}
169+ out <- provider.EventDone{Stop: provider.StopEnd}
170+ default:
171+ c.retried <- req
172+ if c.overflowRetry {
173+ out <- provider.EventDone{
174+ Stop: provider.StopError,
175+ Err: errors.New("context window exceeded after compaction"),
176+ }
177+ return
178+ }
179+ out <- provider.EventDone{
180+ Stop: provider.StopEnd,
181+ Message: provider.Message{
182+ Role: provider.RoleAssistant,
183+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
184+ },
185+ }
186+ }
187+ }()
188+ return out, nil
189+}
190+
191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
192+ client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
193+ agent := core.NewAgent(client, "test-model", "", nil)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
197+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
198+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
199+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
200+ })
201+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
202+ interactive.runCtx = context.Background()
203+
204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
206+
207+ select {
208+ case req := <-client.retried:
209+ if got := requestUserTextCount(req, "retry me"); got != 1 {
210+ t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
211+ }
212+ if got := requestImageCount(req, image); got != 1 {
213+ t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
214+ }
215+ case <-time.After(2 * time.Second):
216+ t.Fatal("context-window error did not compact and retry the prompt")
217+ }
218+}
219+
220+func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
221+ client := &contextRecoveryClient{
222+ retried: make(chan provider.Request, 2),
223+ overflowRetry: true,
224+ }
225+ agent := core.NewAgent(client, "test-model", "", nil)
226+ agent.SetMessages([]provider.Message{
227+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
228+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
229+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
230+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
231+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
232+ })
233+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
234+ interactive.runCtx = context.Background()
235+
236+ interactive.startTurn(context.Background(), "still too large")
237+ select {
238+ case <-client.retried:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("context-window recovery did not retry")
241+ }
242+
243+ deadline := time.NewTimer(2 * time.Second)
244+ defer deadline.Stop()
245+ poll := time.NewTicker(time.Millisecond)
246+ defer poll.Stop()
247+ for {
248+ interactive.mu.Lock()
249+ idleWithError := !interactive.busy && interactive.statusErr != ""
250+ interactive.mu.Unlock()
251+ if idleWithError {
252+ break
253+ }
254+ select {
255+ case <-deadline.C:
256+ t.Fatal("second context-window error did not settle")
257+ case <-poll.C:
258+ }
259+ }
260+
261+ client.mu.Lock()
262+ calls := client.calls
263+ client.mu.Unlock()
264+ if calls != 3 {
265+ t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
266+ }
267+}
268+
269+func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ err error
273+ want bool
274+ }{
275+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
276+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
277+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
278+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
279+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
280+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
281+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ if got := isContextOverflowError(tt.err); got != tt.want {
287+ t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
96293 func requestContainsUserText(req provider.Request, want string) bool {
294+ return requestUserTextCount(req, want) > 0
295+}
296+
297+func requestUserTextCount(req provider.Request, want string) int {
298+ count := 0
97299 for _, message := range req.Messages {
98300 if message.Role != provider.RoleUser {
99301 continue
100302 }
101303 for _, content := range message.Content {
102304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
103- return true
305+ count++
306+ }
307+ }
308+ }
309+ return count
310+}
311+
312+func requestImageCount(req provider.Request, want provider.ImageBlock) int {
313+ count := 0
314+ for _, message := range req.Messages {
315+ if message.Role != provider.RoleUser {
316+ continue
317+ }
318+ for _, content := range message.Content {
319+ if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
320+ count++
104321 }
105322 }
106323 }
107- return false
324+ return count
108325 }
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -93,16 +94,232 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
93 }94 }
94 }95 }
95 96
97+func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98+ client := &compactQueueClient{
99+ compactionStarted: make(chan struct{}),
100+ releaseCompaction: make(chan struct{}),
101+ followUpRequest: make(chan provider.Request, 1),
102+ }
103+ agent := core.NewAgent(client, "test-model", "", nil)
104+ agent.SetMessages([]provider.Message{
105+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
106+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
107+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
108+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110+ })
111+ threshold := 70
112+ interactive := NewInteractive(InteractiveConfig{
113+ Agent: agent,
114+ Provider: "anthropic",
115+ Model: "claude-sonnet-4-5",
116+ AutoCompactThreshold: &threshold,
117+ })
118+ interactive.runCtx = context.Background()
119+ interactive.lastCtxInput = 150000
120+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
121+
122+ interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
123+ select {
124+ case <-client.compactionStarted:
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("pre-turn compaction did not start")
127+ }
128+ close(client.releaseCompaction)
129+
130+ select {
131+ case req := <-client.followUpRequest:
132+ if got := requestUserTextCount(req, "follow up"); got != 1 {
133+ t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
134+ }
135+ if got := requestImageCount(req, image); got != 1 {
136+ t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
137+ }
138+ case <-time.After(2 * time.Second):
139+ t.Fatal("pre-turn compaction did not start the pending image request")
140+ }
141+}
142+
143+type contextRecoveryClient struct {
144+ mu sync.Mutex
145+ calls int
146+ retried chan provider.Request
147+ overflowRetry bool
148+}
149+
150+func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
151+
152+func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
153+ c.mu.Lock()
154+ c.calls++
155+ call := c.calls
156+ c.mu.Unlock()
157+
158+ out := make(chan provider.Event, 2)
159+ go func() {
160+ defer close(out)
161+ switch call {
162+ case 1:
163+ out <- provider.EventDone{
164+ Stop: provider.StopError,
165+ Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
166+ }
167+ case 2:
168+ out <- provider.EventTextDelta{Delta: "summary"}
169+ out <- provider.EventDone{Stop: provider.StopEnd}
170+ default:
171+ c.retried <- req
172+ if c.overflowRetry {
173+ out <- provider.EventDone{
174+ Stop: provider.StopError,
175+ Err: errors.New("context window exceeded after compaction"),
176+ }
177+ return
178+ }
179+ out <- provider.EventDone{
180+ Stop: provider.StopEnd,
181+ Message: provider.Message{
182+ Role: provider.RoleAssistant,
183+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
184+ },
185+ }
186+ }
187+ }()
188+ return out, nil
189+}
190+
191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
192+ client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
193+ agent := core.NewAgent(client, "test-model", "", nil)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
197+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
198+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
199+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
200+ })
201+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
202+ interactive.runCtx = context.Background()
203+
204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
206+
207+ select {
208+ case req := <-client.retried:
209+ if got := requestUserTextCount(req, "retry me"); got != 1 {
210+ t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
211+ }
212+ if got := requestImageCount(req, image); got != 1 {
213+ t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
214+ }
215+ case <-time.After(2 * time.Second):
216+ t.Fatal("context-window error did not compact and retry the prompt")
217+ }
218+}
219+
220+func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
221+ client := &contextRecoveryClient{
222+ retried: make(chan provider.Request, 2),
223+ overflowRetry: true,
224+ }
225+ agent := core.NewAgent(client, "test-model", "", nil)
226+ agent.SetMessages([]provider.Message{
227+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
228+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
229+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
230+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
231+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
232+ })
233+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
234+ interactive.runCtx = context.Background()
235+
236+ interactive.startTurn(context.Background(), "still too large")
237+ select {
238+ case <-client.retried:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("context-window recovery did not retry")
241+ }
242+
243+ deadline := time.NewTimer(2 * time.Second)
244+ defer deadline.Stop()
245+ poll := time.NewTicker(time.Millisecond)
246+ defer poll.Stop()
247+ for {
248+ interactive.mu.Lock()
249+ idleWithError := !interactive.busy && interactive.statusErr != ""
250+ interactive.mu.Unlock()
251+ if idleWithError {
252+ break
253+ }
254+ select {
255+ case <-deadline.C:
256+ t.Fatal("second context-window error did not settle")
257+ case <-poll.C:
258+ }
259+ }
260+
261+ client.mu.Lock()
262+ calls := client.calls
263+ client.mu.Unlock()
264+ if calls != 3 {
265+ t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
266+ }
267+}
268+
269+func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ err error
273+ want bool
274+ }{
275+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
276+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
277+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
278+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
279+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
280+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
281+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ if got := isContextOverflowError(tt.err); got != tt.want {
287+ t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
96 func requestContainsUserText(req provider.Request, want string) bool {293 func requestContainsUserText(req provider.Request, want string) bool {
294+ return requestUserTextCount(req, want) > 0
295+}
296+
297+func requestUserTextCount(req provider.Request, want string) int {
298+ count := 0
97 for _, message := range req.Messages {299 for _, message := range req.Messages {
98 if message.Role != provider.RoleUser {300 if message.Role != provider.RoleUser {
99 continue301 continue
100 }302 }
101 for _, content := range message.Content {303 for _, content := range message.Content {
102 if text, ok := content.(provider.TextBlock); ok && text.Text == want {304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
103- return true305+ count++
306+ }
307+ }
308+ }
309+ return count
310+}
311+
312+func requestImageCount(req provider.Request, want provider.ImageBlock) int {
313+ count := 0
314+ for _, message := range req.Messages {
315+ if message.Role != provider.RoleUser {
316+ continue
317+ }
318+ for _, content := range message.Content {
319+ if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
320+ count++
104 }321 }
105 }322 }
106 }323 }
107- return false324+ return count
108 }325 }
M packages/agent/modes/interactive.go +84 -28
@@ -426,6 +426,14 @@ type Interactive struct {
426426 // pre-turn fraction guard. Cleared by runCompact once shown.
427427 pendingPostCompactNote string
428428
429+ // A pre-turn compaction must preserve the complete pending request,
430+ // including images. Overflow recovery instead continues the user message
431+ // already appended to the agent transcript, avoiding a duplicate message.
432+ pendingCompactPrompt string
433+ pendingCompactImages []provider.ImageBlock
434+ hasPendingCompactPrompt bool
435+ continueAfterCompact bool
436+
429437 // compacting is true for both manual and automatic compaction. Prompts
430438 // submitted while it is set stay in the host queue because Compact does
431439 // not run the agent loop that drains the agent-owned queue.
@@ -5158,6 +5166,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
51585166 }
51595167 }
51605168
5169+// clearPendingCompactTurnLocked drops work that only makes sense after the
5170+// current compaction. The caller must hold i.mu.
5171+func (i *Interactive) clearPendingCompactTurnLocked() {
5172+ i.pendingCompactPrompt = ""
5173+ i.pendingCompactImages = nil
5174+ i.hasPendingCompactPrompt = false
5175+ i.continueAfterCompact = false
5176+ i.pendingPostCompactNote = ""
5177+}
5178+
51615179 // runCompact invokes core.Agent.Compact and reflects the progress in
51625180 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
51635181 // cancel via the same cancelTurn channel used for normal turns.
@@ -5205,10 +5223,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52055223 i.cancelTurn = nil
52065224 i.autoCompacting = false
52075225
5208- // Drain the queue: if the user typed a prompt while compacting,
5209- // fire it now that the transcript is clean.
5226+ // Drain pending work after the transcript is clean. Overflow recovery
5227+ // continues the user message already in the transcript. Pre-turn
5228+ // compaction preserves its complete text-and-images request. Regular
5229+ // prompts typed during compaction remain in the host queue.
52105230 var next string
5231+ var nextImages []provider.ImageBlock
52115232 var hasNext bool
5233+ var continueExisting bool
52125234
52135235 switch {
52145236 case err != nil && ctx.Err() != nil:
@@ -5219,6 +5241,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52195241 i.statusOK = "compaction cancelled"
52205242 }
52215243 i.queued = nil // drop queue on cancel
5244+ i.clearPendingCompactTurnLocked()
52225245 if i.agent != nil {
52235246 i.agent.DrainQueuedMessages()
52245247 }
@@ -5226,6 +5249,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52265249 i.statusErr = "compaction failed: " + err.Error()
52275250 i.statusOK = ""
52285251 i.queued = nil // drop queue on error
5252+ i.clearPendingCompactTurnLocked()
52295253 if i.agent != nil {
52305254 i.agent.DrainQueuedMessages()
52315255 }
@@ -5252,8 +5276,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52525276 i.toolOrder = nil
52535277 i.toolGate = map[string]int{}
52545278 i.view.InvalidateRenderCache()
5255- // Pop queued prompt if any.
5256- if len(i.queued) > 0 {
5279+ switch {
5280+ case i.continueAfterCompact:
5281+ continueExisting = true
5282+ i.continueAfterCompact = false
5283+ case i.hasPendingCompactPrompt:
5284+ next = i.pendingCompactPrompt
5285+ nextImages = i.pendingCompactImages
5286+ i.pendingCompactPrompt = ""
5287+ i.pendingCompactImages = nil
5288+ i.hasPendingCompactPrompt = false
5289+ hasNext = true
5290+ case len(i.queued) > 0:
52575291 next, i.queued = i.queued[0], i.queued[1:]
52585292 hasNext = true
52595293 }
@@ -5261,12 +5295,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52615295 i.mu.Unlock()
52625296 i.invalidate()
52635297
5264- if hasNext {
5298+ if hasNext || continueExisting {
52655299 p := i.runCtx
52665300 if p == nil {
52675301 p = context.Background()
52685302 }
5269- i.startTurn(p, next)
5303+ if continueExisting {
5304+ i.startTurnRequest(p, "", nil, true)
5305+ } else {
5306+ i.startTurnWithImages(p, next, nextImages)
5307+ }
52705308 }
52715309 }()
52725310 }
@@ -5377,6 +5415,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
53775415 }
53785416
53795417 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5418+ i.startTurnRequest(parent, prompt, images, false)
5419+}
5420+
5421+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
53805422 if i.agent == nil {
53815423 return
53825424 }
@@ -5386,11 +5428,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
53865428 // The condense flow re-fires the user's queued prompt for us, so
53875429 // we just hand it off and exit.
53885430 i.mu.Lock()
5389- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
5431+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
53905432 if needsPreCompact {
5391- if prompt != "" {
5392- i.queued = append([]string{prompt}, i.queued...)
5393- }
5433+ i.pendingCompactPrompt = prompt
5434+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5435+ i.hasPendingCompactPrompt = true
53945436 i.statusErr = ""
53955437 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
53965438 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5448,7 +5490,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54485490 }
54495491
54505492 go func() {
5451- err := i.agent.Prompt(ctx, prompt, images, sink)
5493+ var err error
5494+ if overflowRecoveryAttempted {
5495+ err = i.agent.Continue(ctx, sink)
5496+ } else {
5497+ err = i.agent.Prompt(ctx, prompt, images, sink)
5498+ }
54525499 i.mu.Lock()
54535500 i.busy = false
54545501 // Don't touch streamPending / streamFlushPending here — the
@@ -5489,15 +5536,16 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54895536 i.statusErr = ""
54905537 }
54915538 }
5492- // Detect HTTP 413 "payload too large" responses. The provider
5493- // rejected the request because the request body exceeded its
5494- // per-request limit. Token-based auto-compact can miss this
5495- // because the limit is on raw bytes, not tokens. Re-queue the
5496- // prompt so it survives the condense pass and trigger one.
5497- payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
5498- if payloadTooLarge {
5539+ // Detect responses that reject the current context, either as an
5540+ // HTTP 413 payload limit or a model context-window error. Token-
5541+ // based auto-compact can miss both when metadata is stale or the
5542+ // limit is measured in raw bytes. Compact once, then continue the
5543+ // user message already present in the transcript.
5544+ contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5545+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5546+ if recoverContextOverflow {
54995547 i.statusErr = ""
5500- i.queued = append([]string{prompt}, i.queued...)
5548+ i.continueAfterCompact = true
55015549 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
55025550 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
55035551 }
@@ -5522,7 +5570,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55225570 }
55235571 // If the turn was cancelled or errored, drop the queue so the
55245572 // user isn't bombarded with stale messages after an interrupt.
5525- if ctx.Err() != nil || err != nil {
5573+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
55265574 i.queued = nil
55275575 if i.agent != nil {
55285576 i.agent.DrainQueuedMessages()
@@ -5548,7 +5596,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55485596 i.startTurn(parent, next)
55495597 case offer:
55505598 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5551- case payloadTooLarge:
5599+ case recoverContextOverflow:
55525600 i.runCompact(parent, true)
55535601 case shouldAutoCompact:
55545602 i.runCompact(parent, true)
@@ -5627,17 +5675,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
56275675 return " " + th.FG256(th.Warning, "⚠ "+msg)
56285676 }
56295677
5630-// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
5631-// provider clients. The error formatting differs slightly between
5632-// providers (anthropic and openai both prepend the status code), so
5633-// we look for the canonical 413 marker as well as the conventional
5634-// 'payload too large' phrase.
5635-func isPayloadTooLargeError(err error) bool {
5678+// isContextOverflowError matches provider responses that reject the
5679+// current request because its raw payload or tokenized input is too
5680+// large. Providers surface this as either HTTP 413 or semantic context
5681+// errors with differing codes and messages.
5682+func isContextOverflowError(err error) bool {
56365683 if err == nil {
56375684 return false
56385685 }
56395686 msg := strings.ToLower(err.Error())
5640- return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
5687+ return strings.Contains(msg, "http 413") ||
5688+ strings.Contains(msg, " 413") ||
5689+ strings.HasPrefix(msg, "413 ") ||
5690+ strings.Contains(msg, "payload too large") ||
5691+ strings.Contains(msg, "request entity too large") ||
5692+ strings.Contains(msg, "input exceeds the context window") ||
5693+ strings.Contains(msg, "context window exceeded") ||
5694+ strings.Contains(msg, "maximum context length") ||
5695+ strings.Contains(msg, "context_length_exceeded") ||
5696+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
56415697 }
56425698
56435699 const defaultAutoCompactThreshold = 85
@@ -426,6 +426,14 @@ type Interactive struct {
426 // pre-turn fraction guard. Cleared by runCompact once shown.426 // pre-turn fraction guard. Cleared by runCompact once shown.
427 pendingPostCompactNote string427 pendingPostCompactNote string
428 428
429+ // A pre-turn compaction must preserve the complete pending request,
430+ // including images. Overflow recovery instead continues the user message
431+ // already appended to the agent transcript, avoiding a duplicate message.
432+ pendingCompactPrompt string
433+ pendingCompactImages []provider.ImageBlock
434+ hasPendingCompactPrompt bool
435+ continueAfterCompact bool
436+
429 // compacting is true for both manual and automatic compaction. Prompts437 // compacting is true for both manual and automatic compaction. Prompts
430 // submitted while it is set stay in the host queue because Compact does438 // submitted while it is set stay in the host queue because Compact does
431 // not run the agent loop that drains the agent-owned queue.439 // not run the agent loop that drains the agent-owned queue.
@@ -5158,6 +5166,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5158 }5166 }
5159 }5167 }
5160 5168
5169+// clearPendingCompactTurnLocked drops work that only makes sense after the
5170+// current compaction. The caller must hold i.mu.
5171+func (i *Interactive) clearPendingCompactTurnLocked() {
5172+ i.pendingCompactPrompt = ""
5173+ i.pendingCompactImages = nil
5174+ i.hasPendingCompactPrompt = false
5175+ i.continueAfterCompact = false
5176+ i.pendingPostCompactNote = ""
5177+}
5178+
5161 // runCompact invokes core.Agent.Compact and reflects the progress in5179 // runCompact invokes core.Agent.Compact and reflects the progress in
5162 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c5180 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5163 // cancel via the same cancelTurn channel used for normal turns.5181 // cancel via the same cancelTurn channel used for normal turns.
@@ -5205,10 +5223,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5205 i.cancelTurn = nil5223 i.cancelTurn = nil
5206 i.autoCompacting = false5224 i.autoCompacting = false
5207 5225
5208- // Drain the queue: if the user typed a prompt while compacting,5226+ // Drain pending work after the transcript is clean. Overflow recovery
5209- // fire it now that the transcript is clean.5227+ // continues the user message already in the transcript. Pre-turn
5228+ // compaction preserves its complete text-and-images request. Regular
5229+ // prompts typed during compaction remain in the host queue.
5210 var next string5230 var next string
5231+ var nextImages []provider.ImageBlock
5211 var hasNext bool5232 var hasNext bool
5233+ var continueExisting bool
5212 5234
5213 switch {5235 switch {
5214 case err != nil && ctx.Err() != nil:5236 case err != nil && ctx.Err() != nil:
@@ -5219,6 +5241,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5219 i.statusOK = "compaction cancelled"5241 i.statusOK = "compaction cancelled"
5220 }5242 }
5221 i.queued = nil // drop queue on cancel5243 i.queued = nil // drop queue on cancel
5244+ i.clearPendingCompactTurnLocked()
5222 if i.agent != nil {5245 if i.agent != nil {
5223 i.agent.DrainQueuedMessages()5246 i.agent.DrainQueuedMessages()
5224 }5247 }
@@ -5226,6 +5249,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5226 i.statusErr = "compaction failed: " + err.Error()5249 i.statusErr = "compaction failed: " + err.Error()
5227 i.statusOK = ""5250 i.statusOK = ""
5228 i.queued = nil // drop queue on error5251 i.queued = nil // drop queue on error
5252+ i.clearPendingCompactTurnLocked()
5229 if i.agent != nil {5253 if i.agent != nil {
5230 i.agent.DrainQueuedMessages()5254 i.agent.DrainQueuedMessages()
5231 }5255 }
@@ -5252,8 +5276,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5252 i.toolOrder = nil5276 i.toolOrder = nil
5253 i.toolGate = map[string]int{}5277 i.toolGate = map[string]int{}
5254 i.view.InvalidateRenderCache()5278 i.view.InvalidateRenderCache()
5255- // Pop queued prompt if any.5279+ switch {
5256- if len(i.queued) > 0 {5280+ case i.continueAfterCompact:
5281+ continueExisting = true
5282+ i.continueAfterCompact = false
5283+ case i.hasPendingCompactPrompt:
5284+ next = i.pendingCompactPrompt
5285+ nextImages = i.pendingCompactImages
5286+ i.pendingCompactPrompt = ""
5287+ i.pendingCompactImages = nil
5288+ i.hasPendingCompactPrompt = false
5289+ hasNext = true
5290+ case len(i.queued) > 0:
5257 next, i.queued = i.queued[0], i.queued[1:]5291 next, i.queued = i.queued[0], i.queued[1:]
5258 hasNext = true5292 hasNext = true
5259 }5293 }
@@ -5261,12 +5295,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5261 i.mu.Unlock()5295 i.mu.Unlock()
5262 i.invalidate()5296 i.invalidate()
5263 5297
5264- if hasNext {5298+ if hasNext || continueExisting {
5265 p := i.runCtx5299 p := i.runCtx
5266 if p == nil {5300 if p == nil {
5267 p = context.Background()5301 p = context.Background()
5268 }5302 }
5269- i.startTurn(p, next)5303+ if continueExisting {
5304+ i.startTurnRequest(p, "", nil, true)
5305+ } else {
5306+ i.startTurnWithImages(p, next, nextImages)
5307+ }
5270 }5308 }
5271 }()5309 }()
5272 }5310 }
@@ -5377,6 +5415,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
5377 }5415 }
5378 5416
5379 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {5417 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5418+ i.startTurnRequest(parent, prompt, images, false)
5419+}
5420+
5421+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5380 if i.agent == nil {5422 if i.agent == nil {
5381 return5423 return
5382 }5424 }
@@ -5386,11 +5428,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5386 // The condense flow re-fires the user's queued prompt for us, so5428 // The condense flow re-fires the user's queued prompt for us, so
5387 // we just hand it off and exit.5429 // we just hand it off and exit.
5388 i.mu.Lock()5430 i.mu.Lock()
5389- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()5431+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5390 if needsPreCompact {5432 if needsPreCompact {
5391- if prompt != "" {5433+ i.pendingCompactPrompt = prompt
5392- i.queued = append([]string{prompt}, i.queued...)5434+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5393- }5435+ i.hasPendingCompactPrompt = true
5394 i.statusErr = ""5436 i.statusErr = ""
5395 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))5437 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5396 i.pendingPostCompactNote = "context auto-compacted; sending your last message"5438 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5448,7 +5490,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5448 }5490 }
5449 5491
5450 go func() {5492 go func() {
5451- err := i.agent.Prompt(ctx, prompt, images, sink)5493+ var err error
5494+ if overflowRecoveryAttempted {
5495+ err = i.agent.Continue(ctx, sink)
5496+ } else {
5497+ err = i.agent.Prompt(ctx, prompt, images, sink)
5498+ }
5452 i.mu.Lock()5499 i.mu.Lock()
5453 i.busy = false5500 i.busy = false
5454 // Don't touch streamPending / streamFlushPending here — the5501 // Don't touch streamPending / streamFlushPending here — the
@@ -5489,15 +5536,16 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5489 i.statusErr = ""5536 i.statusErr = ""
5490 }5537 }
5491 }5538 }
5492- // Detect HTTP 413 "payload too large" responses. The provider5539+ // Detect responses that reject the current context, either as an
5493- // rejected the request because the request body exceeded its5540+ // HTTP 413 payload limit or a model context-window error. Token-
5494- // per-request limit. Token-based auto-compact can miss this5541+ // based auto-compact can miss both when metadata is stale or the
5495- // because the limit is on raw bytes, not tokens. Re-queue the5542+ // limit is measured in raw bytes. Compact once, then continue the
5496- // prompt so it survives the condense pass and trigger one.5543+ // user message already present in the transcript.
5497- payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)5544+ contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5498- if payloadTooLarge {5545+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5546+ if recoverContextOverflow {
5499 i.statusErr = ""5547 i.statusErr = ""
5500- i.queued = append([]string{prompt}, i.queued...)5548+ i.continueAfterCompact = true
5501 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))5549 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5502 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"5550 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5503 }5551 }
@@ -5522,7 +5570,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5522 }5570 }
5523 // If the turn was cancelled or errored, drop the queue so the5571 // If the turn was cancelled or errored, drop the queue so the
5524 // user isn't bombarded with stale messages after an interrupt.5572 // user isn't bombarded with stale messages after an interrupt.
5525- if ctx.Err() != nil || err != nil {5573+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
5526 i.queued = nil5574 i.queued = nil
5527 if i.agent != nil {5575 if i.agent != nil {
5528 i.agent.DrainQueuedMessages()5576 i.agent.DrainQueuedMessages()
@@ -5548,7 +5596,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5548 i.startTurn(parent, next)5596 i.startTurn(parent, next)
5549 case offer:5597 case offer:
5550 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)5598 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5551- case payloadTooLarge:5599+ case recoverContextOverflow:
5552 i.runCompact(parent, true)5600 i.runCompact(parent, true)
5553 case shouldAutoCompact:5601 case shouldAutoCompact:
5554 i.runCompact(parent, true)5602 i.runCompact(parent, true)
@@ -5627,17 +5675,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
5627 return " " + th.FG256(th.Warning, "⚠ "+msg)5675 return " " + th.FG256(th.Warning, "⚠ "+msg)
5628 }5676 }
5629 5677
5630-// isPayloadTooLargeError matches HTTP 413 responses surfaced by the5678+// isContextOverflowError matches provider responses that reject the
5631-// provider clients. The error formatting differs slightly between5679+// current request because its raw payload or tokenized input is too
5632-// providers (anthropic and openai both prepend the status code), so5680+// large. Providers surface this as either HTTP 413 or semantic context
5633-// we look for the canonical 413 marker as well as the conventional5681+// errors with differing codes and messages.
5634-// 'payload too large' phrase.5682+func isContextOverflowError(err error) bool {
5635-func isPayloadTooLargeError(err error) bool {
5636 if err == nil {5683 if err == nil {
5637 return false5684 return false
5638 }5685 }
5639 msg := strings.ToLower(err.Error())5686 msg := strings.ToLower(err.Error())
5640- return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")5687+ return strings.Contains(msg, "http 413") ||
5688+ strings.Contains(msg, " 413") ||
5689+ strings.HasPrefix(msg, "413 ") ||
5690+ strings.Contains(msg, "payload too large") ||
5691+ strings.Contains(msg, "request entity too large") ||
5692+ strings.Contains(msg, "input exceeds the context window") ||
5693+ strings.Contains(msg, "context window exceeded") ||
5694+ strings.Contains(msg, "maximum context length") ||
5695+ strings.Contains(msg, "context_length_exceeded") ||
5696+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
5641 }5697 }
5642 5698
5643 const defaultAutoCompactThreshold = 855699 const defaultAutoCompactThreshold = 85
M packages/agent/modes/rescue_dialog.go +3 -3
@@ -231,9 +231,9 @@ func classifyRescueError(err error) (bool, string) {
231231 msg := err.Error()
232232 low := strings.ToLower(msg)
233233
234- // Don't trigger on payload-too-large; that path already has its
235- // own auto-compact handling.
236- if isPayloadTooLargeError(err) {
234+ // Don't trigger on context overflow; that path already has its own
235+ // auto-compact handling.
236+ if isContextOverflowError(err) {
237237 return false, ""
238238 }
239239
@@ -231,9 +231,9 @@ func classifyRescueError(err error) (bool, string) {
231 msg := err.Error()231 msg := err.Error()
232 low := strings.ToLower(msg)232 low := strings.ToLower(msg)
233 233
234- // Don't trigger on payload-too-large; that path already has its234+ // Don't trigger on context overflow; that path already has its own
235- // own auto-compact handling.235+ // auto-compact handling.
236- if isPayloadTooLargeError(err) {236+ if isContextOverflowError(err) {
237 return false, ""237 return false, ""
238 }238 }
239 239
M packages/provider/models.go +3 -3
@@ -370,19 +370,19 @@ var Catalog = []Model{
370370 },
371371 {
372372 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
373- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
373+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
374374 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
375375 Speculative: true,
376376 },
377377 {
378378 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
379- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
379+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
380380 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
381381 Speculative: true,
382382 },
383383 {
384384 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
385- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
385+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
386386 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
387387 Speculative: true,
388388 },
@@ -370,19 +370,19 @@ var Catalog = []Model{
370 },370 },
371 {371 {
372 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",372 Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
373- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,373+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
374 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,374 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
375 Speculative: true,375 Speculative: true,
376 },376 },
377 {377 {
378 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",378 Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
379- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,379+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
380 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,380 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
381 Speculative: true,381 Speculative: true,
382 },382 },
383 {383 {
384 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",384 Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
385- ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,385+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
386 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,386 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
387 Speculative: true,387 Speculative: true,
388 },388 },
M packages/provider/provider_test.go +3 -3
@@ -436,9 +436,9 @@ func TestGPT56CatalogEntries(t *testing.T) {
436436 {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
437437 {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
438438 {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
439- {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25},
440- {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25},
441- {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125},
439+ {"openai-codex", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
440+ {"openai-codex", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
441+ {"openai-codex", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
442442 {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
443443 {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
444444 {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
@@ -436,9 +436,9 @@ func TestGPT56CatalogEntries(t *testing.T) {
436 {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},436 {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
437 {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},437 {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
438 {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},438 {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
439- {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25},439+ {"openai-codex", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
440- {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25},440+ {"openai-codex", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
441- {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125},441+ {"openai-codex", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
442 {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},442 {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
443 {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},443 {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
444 {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},444 {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},

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