detached ddda7bd

fix(agent): resume truncated work after auto compact

commit
ddda7bddf2b96521888b29bdae546a7b6c76e87b
author
brice · · 2026-08-06 18:45 UTC
committer
brice · · 2026-08-06 19:02 UTC
parent
d7a9ef7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +241 -23
M README.md +2 -2
@@ -405,7 +405,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405405 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
406406 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
407407 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
408-- **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`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
408+- **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`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
409409 - **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.
410410 - **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`.
411411 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.
@@ -422,7 +422,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422422
423423 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%.
424424
425-zut 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 and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. 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.
425+zut 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 and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. 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.
426426
427427 ### `/jail`
428428
@@ -405,7 +405,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.405 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
406 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.406 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
407 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.407 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
408-- **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`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.408+- **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`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
409 - **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.409 - **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.
410 - **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`.410 - **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`.
411 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.411 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.
@@ -422,7 +422,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422 422
423 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%.423 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%.
424 424
425-zut 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 and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. 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.425+zut 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 and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. 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.
426 426
427 ### `/jail`427 ### `/jail`
428 428
M packages/agent/modes/compact_queue_test.go +133 -5
@@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
153153 interactive := NewInteractive(InteractiveConfig{Agent: agent})
154154 interactive.runCtx = context.Background()
155155
156- interactive.runCompact(context.Background(), false)
156+ interactive.runCompact(context.Background(), false, false)
157157 select {
158158 case <-client.compactionStarted:
159159 case <-time.After(2 * time.Second):
@@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct {
178178 compactionStarted chan struct{}
179179 releaseCompaction chan struct{}
180180 followUpRequest chan provider.Request
181+ firstStop provider.StopReason
182+ firstContent []provider.Content
181183
182- mu sync.Mutex
183- calls int
184+ mu sync.Mutex
185+ calls int
186+ followUps int
184187 }
185188
186189 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
@@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
196199 defer close(out)
197200 switch call {
198201 case 1:
202+ stop := c.firstStop
203+ if stop == "" {
204+ stop = provider.StopEnd
205+ }
206+ content := c.firstContent
207+ if len(content) == 0 {
208+ content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
209+ }
199210 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
200211 out <- provider.EventDone{
201- Stop: provider.StopEnd,
212+ Stop: stop,
202213 Message: provider.Message{
203214 Role: provider.RoleAssistant,
204- Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
215+ Content: content,
205216 },
206217 }
207218 case 2:
@@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
214225 out <- provider.EventDone{Stop: provider.StopEnd}
215226 }
216227 default:
228+ c.mu.Lock()
229+ c.followUps++
230+ c.mu.Unlock()
217231 c.followUpRequest <- req
218232 out <- provider.EventDone{
219233 Stop: provider.StopEnd,
@@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261275 }
262276 }
263277
278+func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279+ client := &thresholdAutoCompactClient{
280+ compactionStarted: make(chan struct{}),
281+ releaseCompaction: make(chan struct{}),
282+ followUpRequest: make(chan provider.Request, 1),
283+ firstStop: provider.StopLength,
284+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
285+ }
286+ agent := core.NewAgent(client, "test-model", "", nil)
287+ threshold := 70
288+ interactive := NewInteractive(InteractiveConfig{
289+ Agent: agent,
290+ Provider: "anthropic",
291+ Model: "claude-sonnet-4-5-20250929",
292+ AutoCompactThreshold: &threshold,
293+ })
294+ interactive.runCtx = context.Background()
295+
296+ interactive.startTurn(context.Background(), "initial request")
297+ select {
298+ case <-client.compactionStarted:
299+ case <-time.After(2 * time.Second):
300+ t.Fatal("threshold auto-compaction did not start after truncated text")
301+ }
302+ close(client.releaseCompaction)
303+
304+ select {
305+ case req := <-client.followUpRequest:
306+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
307+ t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
308+ }
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("threshold auto-compaction did not continue truncated text")
311+ }
312+}
313+
314+func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
315+ client := &thresholdAutoCompactClient{
316+ compactionStarted: make(chan struct{}),
317+ releaseCompaction: make(chan struct{}),
318+ followUpRequest: make(chan provider.Request, 2),
319+ firstStop: provider.StopLength,
320+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
321+ }
322+ agent := core.NewAgent(client, "test-model", "", nil)
323+ threshold := 70
324+ interactive := NewInteractive(InteractiveConfig{
325+ Agent: agent,
326+ Provider: "anthropic",
327+ Model: "claude-sonnet-4-5-20250929",
328+ AutoCompactThreshold: &threshold,
329+ })
330+ interactive.runCtx = context.Background()
331+
332+ interactive.startTurn(context.Background(), "initial request")
333+ select {
334+ case <-client.compactionStarted:
335+ case <-time.After(2 * time.Second):
336+ t.Fatal("threshold auto-compaction did not start after truncated text")
337+ }
338+ interactive.SubmitOrQueue("queued follow-up", nil)
339+ close(client.releaseCompaction)
340+
341+ select {
342+ case req := <-client.followUpRequest:
343+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
344+ t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
345+ }
346+ if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
347+ t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
348+ }
349+ case <-time.After(2 * time.Second):
350+ t.Fatal("threshold auto-compaction did not continue truncated text")
351+ }
352+
353+ select {
354+ case req := <-client.followUpRequest:
355+ if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
356+ t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
357+ }
358+ case <-time.After(2 * time.Second):
359+ t.Fatal("queued prompt did not run after forced continuation")
360+ }
361+
362+ deadline := time.NewTimer(2 * time.Second)
363+ defer deadline.Stop()
364+ poll := time.NewTicker(time.Millisecond)
365+ defer poll.Stop()
366+ for {
367+ interactive.mu.Lock()
368+ idle := !interactive.busy
369+ interactive.mu.Unlock()
370+ if idle {
371+ break
372+ }
373+ select {
374+ case <-deadline.C:
375+ t.Fatal("interactive did not settle after forced continuation and queued prompt")
376+ case <-poll.C:
377+ }
378+ }
379+
380+ client.mu.Lock()
381+ calls := client.calls
382+ followUps := client.followUps
383+ client.mu.Unlock()
384+ if calls != 4 {
385+ t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
386+ }
387+ if followUps != 2 {
388+ t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
389+ }
390+}
391+
264392 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
265393 client := &compactQueueClient{
266394 compactionStarted: make(chan struct{}),
@@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
153 interactive := NewInteractive(InteractiveConfig{Agent: agent})153 interactive := NewInteractive(InteractiveConfig{Agent: agent})
154 interactive.runCtx = context.Background()154 interactive.runCtx = context.Background()
155 155
156- interactive.runCompact(context.Background(), false)156+ interactive.runCompact(context.Background(), false, false)
157 select {157 select {
158 case <-client.compactionStarted:158 case <-client.compactionStarted:
159 case <-time.After(2 * time.Second):159 case <-time.After(2 * time.Second):
@@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct {
178 compactionStarted chan struct{}178 compactionStarted chan struct{}
179 releaseCompaction chan struct{}179 releaseCompaction chan struct{}
180 followUpRequest chan provider.Request180 followUpRequest chan provider.Request
181+ firstStop provider.StopReason
182+ firstContent []provider.Content
181 183
182- mu sync.Mutex184+ mu sync.Mutex
183- calls int185+ calls int
186+ followUps int
184 }187 }
185 188
186 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }189 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
@@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
196 defer close(out)199 defer close(out)
197 switch call {200 switch call {
198 case 1:201 case 1:
202+ stop := c.firstStop
203+ if stop == "" {
204+ stop = provider.StopEnd
205+ }
206+ content := c.firstContent
207+ if len(content) == 0 {
208+ content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
209+ }
199 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}210 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
200 out <- provider.EventDone{211 out <- provider.EventDone{
201- Stop: provider.StopEnd,212+ Stop: stop,
202 Message: provider.Message{213 Message: provider.Message{
203 Role: provider.RoleAssistant,214 Role: provider.RoleAssistant,
204- Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},215+ Content: content,
205 },216 },
206 }217 }
207 case 2:218 case 2:
@@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
214 out <- provider.EventDone{Stop: provider.StopEnd}225 out <- provider.EventDone{Stop: provider.StopEnd}
215 }226 }
216 default:227 default:
228+ c.mu.Lock()
229+ c.followUps++
230+ c.mu.Unlock()
217 c.followUpRequest <- req231 c.followUpRequest <- req
218 out <- provider.EventDone{232 out <- provider.EventDone{
219 Stop: provider.StopEnd,233 Stop: provider.StopEnd,
@@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261 }275 }
262 }276 }
263 277
278+func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279+ client := &thresholdAutoCompactClient{
280+ compactionStarted: make(chan struct{}),
281+ releaseCompaction: make(chan struct{}),
282+ followUpRequest: make(chan provider.Request, 1),
283+ firstStop: provider.StopLength,
284+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
285+ }
286+ agent := core.NewAgent(client, "test-model", "", nil)
287+ threshold := 70
288+ interactive := NewInteractive(InteractiveConfig{
289+ Agent: agent,
290+ Provider: "anthropic",
291+ Model: "claude-sonnet-4-5-20250929",
292+ AutoCompactThreshold: &threshold,
293+ })
294+ interactive.runCtx = context.Background()
295+
296+ interactive.startTurn(context.Background(), "initial request")
297+ select {
298+ case <-client.compactionStarted:
299+ case <-time.After(2 * time.Second):
300+ t.Fatal("threshold auto-compaction did not start after truncated text")
301+ }
302+ close(client.releaseCompaction)
303+
304+ select {
305+ case req := <-client.followUpRequest:
306+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
307+ t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
308+ }
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("threshold auto-compaction did not continue truncated text")
311+ }
312+}
313+
314+func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
315+ client := &thresholdAutoCompactClient{
316+ compactionStarted: make(chan struct{}),
317+ releaseCompaction: make(chan struct{}),
318+ followUpRequest: make(chan provider.Request, 2),
319+ firstStop: provider.StopLength,
320+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
321+ }
322+ agent := core.NewAgent(client, "test-model", "", nil)
323+ threshold := 70
324+ interactive := NewInteractive(InteractiveConfig{
325+ Agent: agent,
326+ Provider: "anthropic",
327+ Model: "claude-sonnet-4-5-20250929",
328+ AutoCompactThreshold: &threshold,
329+ })
330+ interactive.runCtx = context.Background()
331+
332+ interactive.startTurn(context.Background(), "initial request")
333+ select {
334+ case <-client.compactionStarted:
335+ case <-time.After(2 * time.Second):
336+ t.Fatal("threshold auto-compaction did not start after truncated text")
337+ }
338+ interactive.SubmitOrQueue("queued follow-up", nil)
339+ close(client.releaseCompaction)
340+
341+ select {
342+ case req := <-client.followUpRequest:
343+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
344+ t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
345+ }
346+ if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
347+ t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
348+ }
349+ case <-time.After(2 * time.Second):
350+ t.Fatal("threshold auto-compaction did not continue truncated text")
351+ }
352+
353+ select {
354+ case req := <-client.followUpRequest:
355+ if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
356+ t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
357+ }
358+ case <-time.After(2 * time.Second):
359+ t.Fatal("queued prompt did not run after forced continuation")
360+ }
361+
362+ deadline := time.NewTimer(2 * time.Second)
363+ defer deadline.Stop()
364+ poll := time.NewTicker(time.Millisecond)
365+ defer poll.Stop()
366+ for {
367+ interactive.mu.Lock()
368+ idle := !interactive.busy
369+ interactive.mu.Unlock()
370+ if idle {
371+ break
372+ }
373+ select {
374+ case <-deadline.C:
375+ t.Fatal("interactive did not settle after forced continuation and queued prompt")
376+ case <-poll.C:
377+ }
378+ }
379+
380+ client.mu.Lock()
381+ calls := client.calls
382+ followUps := client.followUps
383+ client.mu.Unlock()
384+ if calls != 4 {
385+ t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
386+ }
387+ if followUps != 2 {
388+ t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
389+ }
390+}
391+
264 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {392 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
265 client := &compactQueueClient{393 client := &compactQueueClient{
266 compactionStarted: make(chan struct{}),394 compactionStarted: make(chan struct{}),
M packages/agent/modes/interactive.go +14 -10
@@ -5562,7 +5562,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
55625562 case "/skills":
55635563 i.openSkillsDialog()
55645564 case "/compact":
5565- i.runCompact(ctx, false)
5565+ i.runCompact(ctx, false, false)
55665566 case "/study":
55675567 // Canned prompt that tells the agent to read every file
55685568 // in some target so its later turns have the whole thing
@@ -6495,7 +6495,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
64956495 i.pendingPostCompactNote = ""
64966496 }
64976497
6498-const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`
6498+const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.`
64996499
65006500 // startAutoCompactContinuation adds an internal user turn that tells the
65016501 // model to resume the task after threshold compaction. Provider requests
@@ -6530,7 +6530,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
65306530 // When auto is true the spinner message is pinned to "condensing
65316531 // history" and the status bar surfaces "(auto)" next to the context
65326532 // percentage so it's obvious the system triggered this, not the user.
6533-func (i *Interactive) runCompact(parent context.Context, auto bool) {
6533+// forceAutoContinue tells the post-compact hand-off to resume even if the
6534+// transcript tail has visible assistant text, for example after StopLength.
6535+func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {
65346536 if i.agent == nil {
65356537 i.mu.Lock()
65366538 i.statusErr = "not logged in. type /login first."
@@ -6564,7 +6566,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
65646566 // Sink discards deltas — we don't stream the summary to the UI.
65656567 sink := func(delta string) {}
65666568 msgsBefore := i.agent.Messages()
6567- autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
6569+ autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))
65686570 // Keep the usual recent tail when possible, but never let it cover
65696571 // the whole transcript. A short session can still be at 70–90% of
65706572 // a model's window because one prompt or tool result is large; the
@@ -6650,14 +6652,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
66506652 i.pendingCompactImages = nil
66516653 i.hasPendingCompactPrompt = false
66526654 hasNext = true
6655+ case forceAutoContinue && autoContinue:
6656+ continueAutomatically = true
66536657 case len(i.queued) > 0:
66546658 next, i.queued = i.queued[0], i.queued[1:]
66556659 hasNext = true
66566660 case autoContinue:
66576661 // A successful threshold compaction must hand control back to
6658- // an unfinished model turn. Completed text answers should settle
6659- // without an invented extra request; the user can still prompt
6660- // again normally.
6662+ // unfinished work. Completed text answers still settle normally
6663+ // unless the caller knows the text was truncated and forces this
6664+ // continuation.
66616665 continueAutomatically = true
66626666 }
66636667 }
@@ -6868,7 +6872,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
68686872 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
68696873 i.mu.Unlock()
68706874 i.invalidate()
6871- i.runCompact(parent, true)
6875+ i.runCompact(parent, true, false)
68726876 return
68736877 }
68746878 i.mu.Unlock()
@@ -7063,9 +7067,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
70637067 case offer:
70647068 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
70657069 case recoverContextOverflow:
7066- i.runCompact(parent, true)
7070+ i.runCompact(parent, true, false)
70677071 case shouldAutoCompact:
7068- i.runCompact(parent, true)
7072+ i.runCompact(parent, true, lastStop == provider.StopLength)
70697073 }
70707074 }()
70717075 }
@@ -5562,7 +5562,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5562 case "/skills":5562 case "/skills":
5563 i.openSkillsDialog()5563 i.openSkillsDialog()
5564 case "/compact":5564 case "/compact":
5565- i.runCompact(ctx, false)5565+ i.runCompact(ctx, false, false)
5566 case "/study":5566 case "/study":
5567 // Canned prompt that tells the agent to read every file5567 // Canned prompt that tells the agent to read every file
5568 // in some target so its later turns have the whole thing5568 // in some target so its later turns have the whole thing
@@ -6495,7 +6495,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
6495 i.pendingPostCompactNote = ""6495 i.pendingPostCompactNote = ""
6496 }6496 }
6497 6497
6498-const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`6498+const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.`
6499 6499
6500 // startAutoCompactContinuation adds an internal user turn that tells the6500 // startAutoCompactContinuation adds an internal user turn that tells the
6501 // model to resume the task after threshold compaction. Provider requests6501 // model to resume the task after threshold compaction. Provider requests
@@ -6530,7 +6530,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6530 // When auto is true the spinner message is pinned to "condensing6530 // When auto is true the spinner message is pinned to "condensing
6531 // history" and the status bar surfaces "(auto)" next to the context6531 // history" and the status bar surfaces "(auto)" next to the context
6532 // percentage so it's obvious the system triggered this, not the user.6532 // percentage so it's obvious the system triggered this, not the user.
6533-func (i *Interactive) runCompact(parent context.Context, auto bool) {6533+// forceAutoContinue tells the post-compact hand-off to resume even if the
6534+// transcript tail has visible assistant text, for example after StopLength.
6535+func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {
6534 if i.agent == nil {6536 if i.agent == nil {
6535 i.mu.Lock()6537 i.mu.Lock()
6536 i.statusErr = "not logged in. type /login first."6538 i.statusErr = "not logged in. type /login first."
@@ -6564,7 +6566,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6564 // Sink discards deltas — we don't stream the summary to the UI.6566 // Sink discards deltas — we don't stream the summary to the UI.
6565 sink := func(delta string) {}6567 sink := func(delta string) {}
6566 msgsBefore := i.agent.Messages()6568 msgsBefore := i.agent.Messages()
6567- autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)6569+ autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))
6568 // Keep the usual recent tail when possible, but never let it cover6570 // Keep the usual recent tail when possible, but never let it cover
6569 // the whole transcript. A short session can still be at 70–90% of6571 // the whole transcript. A short session can still be at 70–90% of
6570 // a model's window because one prompt or tool result is large; the6572 // a model's window because one prompt or tool result is large; the
@@ -6650,14 +6652,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6650 i.pendingCompactImages = nil6652 i.pendingCompactImages = nil
6651 i.hasPendingCompactPrompt = false6653 i.hasPendingCompactPrompt = false
6652 hasNext = true6654 hasNext = true
6655+ case forceAutoContinue && autoContinue:
6656+ continueAutomatically = true
6653 case len(i.queued) > 0:6657 case len(i.queued) > 0:
6654 next, i.queued = i.queued[0], i.queued[1:]6658 next, i.queued = i.queued[0], i.queued[1:]
6655 hasNext = true6659 hasNext = true
6656 case autoContinue:6660 case autoContinue:
6657 // A successful threshold compaction must hand control back to6661 // A successful threshold compaction must hand control back to
6658- // an unfinished model turn. Completed text answers should settle6662+ // unfinished work. Completed text answers still settle normally
6659- // without an invented extra request; the user can still prompt6663+ // unless the caller knows the text was truncated and forces this
6660- // again normally.6664+ // continuation.
6661 continueAutomatically = true6665 continueAutomatically = true
6662 }6666 }
6663 }6667 }
@@ -6868,7 +6872,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6868 i.pendingPostCompactNote = "context auto-compacted; sending your last message"6872 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
6869 i.mu.Unlock()6873 i.mu.Unlock()
6870 i.invalidate()6874 i.invalidate()
6871- i.runCompact(parent, true)6875+ i.runCompact(parent, true, false)
6872 return6876 return
6873 }6877 }
6874 i.mu.Unlock()6878 i.mu.Unlock()
@@ -7063,9 +7067,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7063 case offer:7067 case offer:
7064 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)7068 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7065 case recoverContextOverflow:7069 case recoverContextOverflow:
7066- i.runCompact(parent, true)7070+ i.runCompact(parent, true, false)
7067 case shouldAutoCompact:7071 case shouldAutoCompact:
7068- i.runCompact(parent, true)7072+ i.runCompact(parent, true, lastStop == provider.StopLength)
7069 }7073 }
7070 }()7074 }()
7071 }7075 }
M packages/agent/systemprompt.go +5 -0
@@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
6464 sb.WriteString(defaultIdentity)
6565 }
6666
67+ sb.WriteString("\n\n")
68+ sb.WriteString(compactedSummaryHandoffInstruction)
69+
6770 if strings.TrimSpace(o.ZutDocsDir) != "" {
6871 sb.WriteString("\n\nZut's own docs are installed under ")
6972 sb.WriteString(o.ZutDocsDir)
@@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
8790 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
8891
8992 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
93+
94+const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
@@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
64 sb.WriteString(defaultIdentity)64 sb.WriteString(defaultIdentity)
65 }65 }
66 66
67+ sb.WriteString("\n\n")
68+ sb.WriteString(compactedSummaryHandoffInstruction)
69+
67 if strings.TrimSpace(o.ZutDocsDir) != "" {70 if strings.TrimSpace(o.ZutDocsDir) != "" {
68 sb.WriteString("\n\nZut's own docs are installed under ")71 sb.WriteString("\n\nZut's own docs are installed under ")
69 sb.WriteString(o.ZutDocsDir)72 sb.WriteString(o.ZutDocsDir)
@@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
87 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.90 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
88 91
89 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`92 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
93+
94+const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
A packages/agent/systemprompt_test.go +19 -0
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
9+ prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"})
10+ if !strings.Contains(prompt, "custom identity") {
11+ t.Fatalf("custom prompt missing:\n%s", prompt)
12+ }
13+ if !strings.Contains(prompt, compactedSummaryHandoffInstruction) {
14+ t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt)
15+ }
16+ if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
17+ t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
18+ }
19+}
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
9+ prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"})
10+ if !strings.Contains(prompt, "custom identity") {
11+ t.Fatalf("custom prompt missing:\n%s", prompt)
12+ }
13+ if !strings.Contains(prompt, compactedSummaryHandoffInstruction) {
14+ t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt)
15+ }
16+ if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
17+ t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
18+ }
19+}
M packages/core/compact.go +6 -4
@@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string {
220220
221221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
222222
223+Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following.
224+
223225 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
224226
225227 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -229,9 +231,9 @@ Use this EXACT format:
229231 ## Goal
230232 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
231233
232-## Constraints & Preferences
233-- [Any constraints, preferences, or requirements mentioned by user]
234-- [Or "(none)" if none were mentioned]
234+## Active Instructions & Preferences
235+- [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.]
236+- [Or "(none)" if none are active]
235237
236238 ## Progress
237239 ### Done
@@ -253,4 +255,4 @@ Use this EXACT format:
253255 - [Any data, examples, or references needed to continue]
254256 - [Or "(none)" if not applicable]
255257
256-Keep each section concise. Preserve exact file paths, function names, and error messages.`
258+Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.`
@@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string {
220 220
221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
222 222
223+Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following.
224+
223 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`225 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
224 226
225 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.227 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -229,9 +231,9 @@ Use this EXACT format:
229 ## Goal231 ## Goal
230 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]232 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
231 233
232-## Constraints & Preferences234+## Active Instructions & Preferences
233-- [Any constraints, preferences, or requirements mentioned by user]235+- [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.]
234-- [Or "(none)" if none were mentioned]236+- [Or "(none)" if none are active]
235 237
236 ## Progress238 ## Progress
237 ### Done239 ### Done
@@ -253,4 +255,4 @@ Use this EXACT format:
253 - [Any data, examples, or references needed to continue]255 - [Any data, examples, or references needed to continue]
254 - [Or "(none)" if not applicable]256 - [Or "(none)" if not applicable]
255 257
256-Keep each section concise. Preserve exact file paths, function names, and error messages.`258+Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.`
M packages/core/compact_lifecycle_test.go +62 -2
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/provider"
@@ -9,20 +10,27 @@ import (
910
1011 type compactLifecycleClient struct {
1112 hadLifecycle bool
13+ req provider.Request
14+ summary string
1215 }
1316
1417 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
1518
1619 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ c.req = req
1721 c.hadLifecycle = req.Lifecycle != nil
1822 if req.Lifecycle != nil {
1923 req.Lifecycle.RequestAttempt(1, 1)
2024 }
25+ summary := c.summary
26+ if summary == "" {
27+ summary = "summary"
28+ }
2129 out := make(chan provider.Event, 2)
22- out <- provider.EventTextDelta{Delta: "summary"}
30+ out <- provider.EventTextDelta{Delta: summary}
2331 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
2432 Role: provider.RoleAssistant,
25- Content: []provider.Content{provider.TextBlock{Text: "summary"}},
33+ Content: []provider.Content{provider.TextBlock{Text: summary}},
2634 }}
2735 close(out)
2836 return out, nil
@@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
6876 t.Fatalf("fourth event = %#v, want summary text delta", events[3])
6977 }
7078 }
79+
80+func TestCompactPromptPreservesActiveInstructions(t *testing.T) {
81+ const activeInstruction = "delegate independent work to subagents"
82+ client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction}
83+ agent := NewAgent(client, "compact-model", "system", Registry{})
84+ agent.SetMessages([]provider.Message{
85+ {
86+ Role: provider.RoleUser,
87+ Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}},
88+ },
89+ {
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "I will do that."}},
92+ },
93+ })
94+
95+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
96+ t.Fatalf("Compact: %v", err)
97+ }
98+ messages := agent.Messages()
99+ if len(messages) != 1 {
100+ t.Fatalf("compacted messages = %d, want 1", len(messages))
101+ }
102+ if messages[0].Role != provider.RoleUser {
103+ t.Fatalf("compacted message role = %q, want user", messages[0].Role)
104+ }
105+ if messages[0].Meta["compaction"] != "true" {
106+ t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta)
107+ }
108+ compacted, ok := messages[0].Content[0].(provider.TextBlock)
109+ if !ok {
110+ t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0])
111+ }
112+ if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) {
113+ t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text)
114+ }
115+ if !strings.Contains(client.req.System, "Preserve active user instructions") {
116+ t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System)
117+ }
118+ if len(client.req.Messages) != 1 {
119+ t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages))
120+ }
121+ text, ok := client.req.Messages[0].Content[0].(provider.TextBlock)
122+ if !ok {
123+ t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0])
124+ }
125+ for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} {
126+ if !strings.Contains(text.Text, want) {
127+ t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text)
128+ }
129+ }
130+}
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
@@ -9,20 +10,27 @@ import (
9 10
10 type compactLifecycleClient struct {11 type compactLifecycleClient struct {
11 hadLifecycle bool12 hadLifecycle bool
13+ req provider.Request
14+ summary string
12 }15 }
13 16
14 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }17 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
15 18
16 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {19 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ c.req = req
17 c.hadLifecycle = req.Lifecycle != nil21 c.hadLifecycle = req.Lifecycle != nil
18 if req.Lifecycle != nil {22 if req.Lifecycle != nil {
19 req.Lifecycle.RequestAttempt(1, 1)23 req.Lifecycle.RequestAttempt(1, 1)
20 }24 }
25+ summary := c.summary
26+ if summary == "" {
27+ summary = "summary"
28+ }
21 out := make(chan provider.Event, 2)29 out := make(chan provider.Event, 2)
22- out <- provider.EventTextDelta{Delta: "summary"}30+ out <- provider.EventTextDelta{Delta: summary}
23 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{31 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
24 Role: provider.RoleAssistant,32 Role: provider.RoleAssistant,
25- Content: []provider.Content{provider.TextBlock{Text: "summary"}},33+ Content: []provider.Content{provider.TextBlock{Text: summary}},
26 }}34 }}
27 close(out)35 close(out)
28 return out, nil36 return out, nil
@@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
68 t.Fatalf("fourth event = %#v, want summary text delta", events[3])76 t.Fatalf("fourth event = %#v, want summary text delta", events[3])
69 }77 }
70 }78 }
79+
80+func TestCompactPromptPreservesActiveInstructions(t *testing.T) {
81+ const activeInstruction = "delegate independent work to subagents"
82+ client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction}
83+ agent := NewAgent(client, "compact-model", "system", Registry{})
84+ agent.SetMessages([]provider.Message{
85+ {
86+ Role: provider.RoleUser,
87+ Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}},
88+ },
89+ {
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "I will do that."}},
92+ },
93+ })
94+
95+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
96+ t.Fatalf("Compact: %v", err)
97+ }
98+ messages := agent.Messages()
99+ if len(messages) != 1 {
100+ t.Fatalf("compacted messages = %d, want 1", len(messages))
101+ }
102+ if messages[0].Role != provider.RoleUser {
103+ t.Fatalf("compacted message role = %q, want user", messages[0].Role)
104+ }
105+ if messages[0].Meta["compaction"] != "true" {
106+ t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta)
107+ }
108+ compacted, ok := messages[0].Content[0].(provider.TextBlock)
109+ if !ok {
110+ t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0])
111+ }
112+ if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) {
113+ t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text)
114+ }
115+ if !strings.Contains(client.req.System, "Preserve active user instructions") {
116+ t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System)
117+ }
118+ if len(client.req.Messages) != 1 {
119+ t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages))
120+ }
121+ text, ok := client.req.Messages[0].Content[0].(provider.TextBlock)
122+ if !ok {
123+ t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0])
124+ }
125+ for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} {
126+ if !strings.Contains(text.Text, want) {
127+ t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text)
128+ }
129+ }
130+}

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