detached 2ed4775

Make context overflow recovery safe

commit
2ed47755fb34ca77bc25969d34c555e7c876e338
author
patriceckhart · · 2026-07-29 11:03 UTC
parent
de925da
signature
unsigned
2 files · +204 -28
M packages/agent/modes/compact_queue_test.go +138 -10
@@ -94,10 +94,57 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
9494 }
9595 }
9696
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+
97143 type contextRecoveryClient struct {
98- mu sync.Mutex
99- calls int
100- retried chan provider.Request
144+ mu sync.Mutex
145+ calls int
146+ retried chan provider.Request
147+ overflowRetry bool
101148 }
102149
103150 func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
@@ -122,6 +169,13 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request)
122169 out <- provider.EventDone{Stop: provider.StopEnd}
123170 default:
124171 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+ }
125179 out <- provider.EventDone{
126180 Stop: provider.StopEnd,
127181 Message: provider.Message{
@@ -134,7 +188,7 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request)
134188 return out, nil
135189 }
136190
137-func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) {
191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
138192 client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
139193 agent := core.NewAgent(client, "test-model", "", nil)
140194 agent.SetMessages([]provider.Message{
@@ -147,18 +201,71 @@ func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) {
147201 interactive := NewInteractive(InteractiveConfig{Agent: agent})
148202 interactive.runCtx = context.Background()
149203
150- interactive.startTurn(context.Background(), "retry me")
204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
151206
152207 select {
153208 case req := <-client.retried:
154- if !requestContainsUserText(req, "retry me") {
155- t.Fatalf("retried request does not contain original prompt: %#v", req.Messages)
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)
156214 }
157215 case <-time.After(2 * time.Second):
158216 t.Fatal("context-window error did not compact and retry the prompt")
159217 }
160218 }
161219
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+
162269 func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
163270 tests := []struct {
164271 name string
@@ -169,7 +276,8 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
169276 {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
170277 {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
171278 {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
172- {name: "maximum token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), 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},
173281 {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
174282 }
175283
@@ -183,15 +291,35 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
183291 }
184292
185293 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
186299 for _, message := range req.Messages {
187300 if message.Role != provider.RoleUser {
188301 continue
189302 }
190303 for _, content := range message.Content {
191304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
192- 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++
193321 }
194322 }
195323 }
196- return false
324+ return count
197325 }
@@ -94,10 +94,57 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
94 }94 }
95 }95 }
96 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+
97 type contextRecoveryClient struct {143 type contextRecoveryClient struct {
98- mu sync.Mutex144+ mu sync.Mutex
99- calls int145+ calls int
100- retried chan provider.Request146+ retried chan provider.Request
147+ overflowRetry bool
101 }148 }
102 149
103 func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }150 func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
@@ -122,6 +169,13 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request)
122 out <- provider.EventDone{Stop: provider.StopEnd}169 out <- provider.EventDone{Stop: provider.StopEnd}
123 default:170 default:
124 c.retried <- req171 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+ }
125 out <- provider.EventDone{179 out <- provider.EventDone{
126 Stop: provider.StopEnd,180 Stop: provider.StopEnd,
127 Message: provider.Message{181 Message: provider.Message{
@@ -134,7 +188,7 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request)
134 return out, nil188 return out, nil
135 }189 }
136 190
137-func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) {191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
138 client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}192 client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
139 agent := core.NewAgent(client, "test-model", "", nil)193 agent := core.NewAgent(client, "test-model", "", nil)
140 agent.SetMessages([]provider.Message{194 agent.SetMessages([]provider.Message{
@@ -147,18 +201,71 @@ func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) {
147 interactive := NewInteractive(InteractiveConfig{Agent: agent})201 interactive := NewInteractive(InteractiveConfig{Agent: agent})
148 interactive.runCtx = context.Background()202 interactive.runCtx = context.Background()
149 203
150- interactive.startTurn(context.Background(), "retry me")204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
151 206
152 select {207 select {
153 case req := <-client.retried:208 case req := <-client.retried:
154- if !requestContainsUserText(req, "retry me") {209+ if got := requestUserTextCount(req, "retry me"); got != 1 {
155- t.Fatalf("retried request does not contain original prompt: %#v", req.Messages)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)
156 }214 }
157 case <-time.After(2 * time.Second):215 case <-time.After(2 * time.Second):
158 t.Fatal("context-window error did not compact and retry the prompt")216 t.Fatal("context-window error did not compact and retry the prompt")
159 }217 }
160 }218 }
161 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+
162 func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {269 func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
163 tests := []struct {270 tests := []struct {
164 name string271 name string
@@ -169,7 +276,8 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
169 {name: "context error code", err: errors.New("context_length_exceeded"), want: true},276 {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
170 {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},277 {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
171 {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},278 {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
172- {name: "maximum token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), 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},
173 {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},281 {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
174 }282 }
175 283
@@ -183,15 +291,35 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
183 }291 }
184 292
185 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
186 for _, message := range req.Messages {299 for _, message := range req.Messages {
187 if message.Role != provider.RoleUser {300 if message.Role != provider.RoleUser {
188 continue301 continue
189 }302 }
190 for _, content := range message.Content {303 for _, content := range message.Content {
191 if text, ok := content.(provider.TextBlock); ok && text.Text == want {304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
192- 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++
193 }321 }
194 }322 }
195 }323 }
196- return false324+ return count
197 }325 }
M packages/agent/modes/interactive.go +66 -18
@@ -421,6 +421,14 @@ type Interactive struct {
421421 // pre-turn fraction guard. Cleared by runCompact once shown.
422422 pendingPostCompactNote string
423423
424+ // A pre-turn compaction must preserve the complete pending request,
425+ // including images. Overflow recovery instead continues the user message
426+ // already appended to the agent transcript, avoiding a duplicate message.
427+ pendingCompactPrompt string
428+ pendingCompactImages []provider.ImageBlock
429+ hasPendingCompactPrompt bool
430+ continueAfterCompact bool
431+
424432 // compacting is true for both manual and automatic compaction. Prompts
425433 // submitted while it is set stay in the host queue because Compact does
426434 // not run the agent loop that drains the agent-owned queue.
@@ -5145,6 +5153,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
51455153 }
51465154 }
51475155
5156+// clearPendingCompactTurnLocked drops work that only makes sense after the
5157+// current compaction. The caller must hold i.mu.
5158+func (i *Interactive) clearPendingCompactTurnLocked() {
5159+ i.pendingCompactPrompt = ""
5160+ i.pendingCompactImages = nil
5161+ i.hasPendingCompactPrompt = false
5162+ i.continueAfterCompact = false
5163+ i.pendingPostCompactNote = ""
5164+}
5165+
51485166 // runCompact invokes core.Agent.Compact and reflects the progress in
51495167 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
51505168 // cancel via the same cancelTurn channel used for normal turns.
@@ -5192,10 +5210,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
51925210 i.cancelTurn = nil
51935211 i.autoCompacting = false
51945212
5195- // Drain the queue: if the user typed a prompt while compacting,
5196- // fire it now that the transcript is clean.
5213+ // Drain pending work after the transcript is clean. Overflow recovery
5214+ // continues the user message already in the transcript. Pre-turn
5215+ // compaction preserves its complete text-and-images request. Regular
5216+ // prompts typed during compaction remain in the host queue.
51975217 var next string
5218+ var nextImages []provider.ImageBlock
51985219 var hasNext bool
5220+ var continueExisting bool
51995221
52005222 switch {
52015223 case err != nil && ctx.Err() != nil:
@@ -5206,6 +5228,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52065228 i.statusOK = "compaction cancelled"
52075229 }
52085230 i.queued = nil // drop queue on cancel
5231+ i.clearPendingCompactTurnLocked()
52095232 if i.agent != nil {
52105233 i.agent.DrainQueuedMessages()
52115234 }
@@ -5213,6 +5236,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52135236 i.statusErr = "compaction failed: " + err.Error()
52145237 i.statusOK = ""
52155238 i.queued = nil // drop queue on error
5239+ i.clearPendingCompactTurnLocked()
52165240 if i.agent != nil {
52175241 i.agent.DrainQueuedMessages()
52185242 }
@@ -5239,8 +5263,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52395263 i.toolOrder = nil
52405264 i.toolGate = map[string]int{}
52415265 i.view.InvalidateRenderCache()
5242- // Pop queued prompt if any.
5243- if len(i.queued) > 0 {
5266+ switch {
5267+ case i.continueAfterCompact:
5268+ continueExisting = true
5269+ i.continueAfterCompact = false
5270+ case i.hasPendingCompactPrompt:
5271+ next = i.pendingCompactPrompt
5272+ nextImages = i.pendingCompactImages
5273+ i.pendingCompactPrompt = ""
5274+ i.pendingCompactImages = nil
5275+ i.hasPendingCompactPrompt = false
5276+ hasNext = true
5277+ case len(i.queued) > 0:
52445278 next, i.queued = i.queued[0], i.queued[1:]
52455279 hasNext = true
52465280 }
@@ -5248,12 +5282,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52485282 i.mu.Unlock()
52495283 i.invalidate()
52505284
5251- if hasNext {
5285+ if hasNext || continueExisting {
52525286 p := i.runCtx
52535287 if p == nil {
52545288 p = context.Background()
52555289 }
5256- i.startTurn(p, next)
5290+ if continueExisting {
5291+ i.startTurnRequest(p, "", nil, true)
5292+ } else {
5293+ i.startTurnWithImages(p, next, nextImages)
5294+ }
52575295 }
52585296 }()
52595297 }
@@ -5364,6 +5402,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
53645402 }
53655403
53665404 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5405+ i.startTurnRequest(parent, prompt, images, false)
5406+}
5407+
5408+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
53675409 if i.agent == nil {
53685410 return
53695411 }
@@ -5373,11 +5415,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
53735415 // The condense flow re-fires the user's queued prompt for us, so
53745416 // we just hand it off and exit.
53755417 i.mu.Lock()
5376- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
5418+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
53775419 if needsPreCompact {
5378- if prompt != "" {
5379- i.queued = append([]string{prompt}, i.queued...)
5380- }
5420+ i.pendingCompactPrompt = prompt
5421+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5422+ i.hasPendingCompactPrompt = true
53815423 i.statusErr = ""
53825424 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
53835425 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5435,7 +5477,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54355477 }
54365478
54375479 go func() {
5438- err := i.agent.Prompt(ctx, prompt, images, sink)
5480+ var err error
5481+ if overflowRecoveryAttempted {
5482+ err = i.agent.Continue(ctx, sink)
5483+ } else {
5484+ err = i.agent.Prompt(ctx, prompt, images, sink)
5485+ }
54395486 i.mu.Lock()
54405487 i.busy = false
54415488 // Don't touch streamPending / streamFlushPending here — the
@@ -5479,12 +5526,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54795526 // Detect responses that reject the current context, either as an
54805527 // HTTP 413 payload limit or a model context-window error. Token-
54815528 // based auto-compact can miss both when metadata is stale or the
5482- // limit is measured in raw bytes. Re-queue the prompt so it
5483- // survives the condense pass and trigger one.
5529+ // limit is measured in raw bytes. Compact once, then continue the
5530+ // user message already present in the transcript.
54845531 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5485- if contextOverflow {
5532+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5533+ if recoverContextOverflow {
54865534 i.statusErr = ""
5487- i.queued = append([]string{prompt}, i.queued...)
5535+ i.continueAfterCompact = true
54885536 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
54895537 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
54905538 }
@@ -5509,7 +5557,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55095557 }
55105558 // If the turn was cancelled or errored, drop the queue so the
55115559 // user isn't bombarded with stale messages after an interrupt.
5512- if ctx.Err() != nil || (err != nil && !contextOverflow) {
5560+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
55135561 i.queued = nil
55145562 if i.agent != nil {
55155563 i.agent.DrainQueuedMessages()
@@ -5535,7 +5583,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55355583 i.startTurn(parent, next)
55365584 case offer:
55375585 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5538- case contextOverflow:
5586+ case recoverContextOverflow:
55395587 i.runCompact(parent, true)
55405588 case shouldAutoCompact:
55415589 i.runCompact(parent, true)
@@ -5632,7 +5680,7 @@ func isContextOverflowError(err error) bool {
56325680 strings.Contains(msg, "context window exceeded") ||
56335681 strings.Contains(msg, "maximum context length") ||
56345682 strings.Contains(msg, "context_length_exceeded") ||
5635- strings.Contains(msg, "exceeds the maximum number of tokens")
5683+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
56365684 }
56375685
56385686 const defaultAutoCompactThreshold = 85
@@ -421,6 +421,14 @@ type Interactive struct {
421 // pre-turn fraction guard. Cleared by runCompact once shown.421 // pre-turn fraction guard. Cleared by runCompact once shown.
422 pendingPostCompactNote string422 pendingPostCompactNote string
423 423
424+ // A pre-turn compaction must preserve the complete pending request,
425+ // including images. Overflow recovery instead continues the user message
426+ // already appended to the agent transcript, avoiding a duplicate message.
427+ pendingCompactPrompt string
428+ pendingCompactImages []provider.ImageBlock
429+ hasPendingCompactPrompt bool
430+ continueAfterCompact bool
431+
424 // compacting is true for both manual and automatic compaction. Prompts432 // compacting is true for both manual and automatic compaction. Prompts
425 // submitted while it is set stay in the host queue because Compact does433 // submitted while it is set stay in the host queue because Compact does
426 // not run the agent loop that drains the agent-owned queue.434 // not run the agent loop that drains the agent-owned queue.
@@ -5145,6 +5153,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5145 }5153 }
5146 }5154 }
5147 5155
5156+// clearPendingCompactTurnLocked drops work that only makes sense after the
5157+// current compaction. The caller must hold i.mu.
5158+func (i *Interactive) clearPendingCompactTurnLocked() {
5159+ i.pendingCompactPrompt = ""
5160+ i.pendingCompactImages = nil
5161+ i.hasPendingCompactPrompt = false
5162+ i.continueAfterCompact = false
5163+ i.pendingPostCompactNote = ""
5164+}
5165+
5148 // runCompact invokes core.Agent.Compact and reflects the progress in5166 // runCompact invokes core.Agent.Compact and reflects the progress in
5149 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c5167 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5150 // cancel via the same cancelTurn channel used for normal turns.5168 // cancel via the same cancelTurn channel used for normal turns.
@@ -5192,10 +5210,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5192 i.cancelTurn = nil5210 i.cancelTurn = nil
5193 i.autoCompacting = false5211 i.autoCompacting = false
5194 5212
5195- // Drain the queue: if the user typed a prompt while compacting,5213+ // Drain pending work after the transcript is clean. Overflow recovery
5196- // fire it now that the transcript is clean.5214+ // continues the user message already in the transcript. Pre-turn
5215+ // compaction preserves its complete text-and-images request. Regular
5216+ // prompts typed during compaction remain in the host queue.
5197 var next string5217 var next string
5218+ var nextImages []provider.ImageBlock
5198 var hasNext bool5219 var hasNext bool
5220+ var continueExisting bool
5199 5221
5200 switch {5222 switch {
5201 case err != nil && ctx.Err() != nil:5223 case err != nil && ctx.Err() != nil:
@@ -5206,6 +5228,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5206 i.statusOK = "compaction cancelled"5228 i.statusOK = "compaction cancelled"
5207 }5229 }
5208 i.queued = nil // drop queue on cancel5230 i.queued = nil // drop queue on cancel
5231+ i.clearPendingCompactTurnLocked()
5209 if i.agent != nil {5232 if i.agent != nil {
5210 i.agent.DrainQueuedMessages()5233 i.agent.DrainQueuedMessages()
5211 }5234 }
@@ -5213,6 +5236,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5213 i.statusErr = "compaction failed: " + err.Error()5236 i.statusErr = "compaction failed: " + err.Error()
5214 i.statusOK = ""5237 i.statusOK = ""
5215 i.queued = nil // drop queue on error5238 i.queued = nil // drop queue on error
5239+ i.clearPendingCompactTurnLocked()
5216 if i.agent != nil {5240 if i.agent != nil {
5217 i.agent.DrainQueuedMessages()5241 i.agent.DrainQueuedMessages()
5218 }5242 }
@@ -5239,8 +5263,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5239 i.toolOrder = nil5263 i.toolOrder = nil
5240 i.toolGate = map[string]int{}5264 i.toolGate = map[string]int{}
5241 i.view.InvalidateRenderCache()5265 i.view.InvalidateRenderCache()
5242- // Pop queued prompt if any.5266+ switch {
5243- if len(i.queued) > 0 {5267+ case i.continueAfterCompact:
5268+ continueExisting = true
5269+ i.continueAfterCompact = false
5270+ case i.hasPendingCompactPrompt:
5271+ next = i.pendingCompactPrompt
5272+ nextImages = i.pendingCompactImages
5273+ i.pendingCompactPrompt = ""
5274+ i.pendingCompactImages = nil
5275+ i.hasPendingCompactPrompt = false
5276+ hasNext = true
5277+ case len(i.queued) > 0:
5244 next, i.queued = i.queued[0], i.queued[1:]5278 next, i.queued = i.queued[0], i.queued[1:]
5245 hasNext = true5279 hasNext = true
5246 }5280 }
@@ -5248,12 +5282,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5248 i.mu.Unlock()5282 i.mu.Unlock()
5249 i.invalidate()5283 i.invalidate()
5250 5284
5251- if hasNext {5285+ if hasNext || continueExisting {
5252 p := i.runCtx5286 p := i.runCtx
5253 if p == nil {5287 if p == nil {
5254 p = context.Background()5288 p = context.Background()
5255 }5289 }
5256- i.startTurn(p, next)5290+ if continueExisting {
5291+ i.startTurnRequest(p, "", nil, true)
5292+ } else {
5293+ i.startTurnWithImages(p, next, nextImages)
5294+ }
5257 }5295 }
5258 }()5296 }()
5259 }5297 }
@@ -5364,6 +5402,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
5364 }5402 }
5365 5403
5366 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {5404 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5405+ i.startTurnRequest(parent, prompt, images, false)
5406+}
5407+
5408+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5367 if i.agent == nil {5409 if i.agent == nil {
5368 return5410 return
5369 }5411 }
@@ -5373,11 +5415,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5373 // The condense flow re-fires the user's queued prompt for us, so5415 // The condense flow re-fires the user's queued prompt for us, so
5374 // we just hand it off and exit.5416 // we just hand it off and exit.
5375 i.mu.Lock()5417 i.mu.Lock()
5376- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()5418+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5377 if needsPreCompact {5419 if needsPreCompact {
5378- if prompt != "" {5420+ i.pendingCompactPrompt = prompt
5379- i.queued = append([]string{prompt}, i.queued...)5421+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5380- }5422+ i.hasPendingCompactPrompt = true
5381 i.statusErr = ""5423 i.statusErr = ""
5382 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))5424 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5383 i.pendingPostCompactNote = "context auto-compacted; sending your last message"5425 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5435,7 +5477,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5435 }5477 }
5436 5478
5437 go func() {5479 go func() {
5438- err := i.agent.Prompt(ctx, prompt, images, sink)5480+ var err error
5481+ if overflowRecoveryAttempted {
5482+ err = i.agent.Continue(ctx, sink)
5483+ } else {
5484+ err = i.agent.Prompt(ctx, prompt, images, sink)
5485+ }
5439 i.mu.Lock()5486 i.mu.Lock()
5440 i.busy = false5487 i.busy = false
5441 // Don't touch streamPending / streamFlushPending here — the5488 // Don't touch streamPending / streamFlushPending here — the
@@ -5479,12 +5526,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5479 // Detect responses that reject the current context, either as an5526 // Detect responses that reject the current context, either as an
5480 // HTTP 413 payload limit or a model context-window error. Token-5527 // HTTP 413 payload limit or a model context-window error. Token-
5481 // based auto-compact can miss both when metadata is stale or the5528 // based auto-compact can miss both when metadata is stale or the
5482- // limit is measured in raw bytes. Re-queue the prompt so it5529+ // limit is measured in raw bytes. Compact once, then continue the
5483- // survives the condense pass and trigger one.5530+ // user message already present in the transcript.
5484 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)5531 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5485- if contextOverflow {5532+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5533+ if recoverContextOverflow {
5486 i.statusErr = ""5534 i.statusErr = ""
5487- i.queued = append([]string{prompt}, i.queued...)5535+ i.continueAfterCompact = true
5488 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))5536 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5489 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"5537 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5490 }5538 }
@@ -5509,7 +5557,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5509 }5557 }
5510 // If the turn was cancelled or errored, drop the queue so the5558 // If the turn was cancelled or errored, drop the queue so the
5511 // user isn't bombarded with stale messages after an interrupt.5559 // user isn't bombarded with stale messages after an interrupt.
5512- if ctx.Err() != nil || (err != nil && !contextOverflow) {5560+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
5513 i.queued = nil5561 i.queued = nil
5514 if i.agent != nil {5562 if i.agent != nil {
5515 i.agent.DrainQueuedMessages()5563 i.agent.DrainQueuedMessages()
@@ -5535,7 +5583,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5535 i.startTurn(parent, next)5583 i.startTurn(parent, next)
5536 case offer:5584 case offer:
5537 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)5585 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5538- case contextOverflow:5586+ case recoverContextOverflow:
5539 i.runCompact(parent, true)5587 i.runCompact(parent, true)
5540 case shouldAutoCompact:5588 case shouldAutoCompact:
5541 i.runCompact(parent, true)5589 i.runCompact(parent, true)
@@ -5632,7 +5680,7 @@ func isContextOverflowError(err error) bool {
5632 strings.Contains(msg, "context window exceeded") ||5680 strings.Contains(msg, "context window exceeded") ||
5633 strings.Contains(msg, "maximum context length") ||5681 strings.Contains(msg, "maximum context length") ||
5634 strings.Contains(msg, "context_length_exceeded") ||5682 strings.Contains(msg, "context_length_exceeded") ||
5635- strings.Contains(msg, "exceeds the maximum number of tokens")5683+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
5636 }5684 }
5637 5685
5638 const defaultAutoCompactThreshold = 855686 const defaultAutoCompactThreshold = 85

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