detached e995d3a

feat(interactive): rescue forward-work status after compaction

commit
e995d3ab7de64043380e6f3175fbb08615cdd05e
author
brice · · 2026-08-07 05:33 UTC
parent
5bf8ac4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +778 -49
M README.md +3 -3
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407407 - **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.
408408 - **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`.
409409 - **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.
410-- **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.
410+- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
411411 - **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.
412412 - **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`.
413413 - **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,9 +422,9 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422422
423423 ### `/compact`
424424
425-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%.
425+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. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426426
427-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.
427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.
428428
429429 ### `/jail`
430430
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407 - **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.407 - **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.
408 - **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`.408 - **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`.
409 - **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.409 - **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.
410-- **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.410+- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
411 - **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.411 - **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.
412 - **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`.412 - **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`.
413 - **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`.413 - **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,9 +422,9 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422 422
423 ### `/compact`423 ### `/compact`
424 424
425-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%.425+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. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426 426
427-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.427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.
428 428
429 ### `/jail`429 ### `/jail`
430 430
M packages/agent/build_test.go +1 -1
@@ -646,7 +646,7 @@ func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
646646 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
647647 t.Setenv("AWS_REGION", "us-east-1")
648648 // Make sure no other provider's env credential pre-empts bedrock.
649- for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY"} {
649+ for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_API_KEY", "OPENCODE_API_KEY"} {
650650 t.Setenv(k, "")
651651 }
652652
@@ -646,7 +646,7 @@ func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
646 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")646 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
647 t.Setenv("AWS_REGION", "us-east-1")647 t.Setenv("AWS_REGION", "us-east-1")
648 // Make sure no other provider's env credential pre-empts bedrock.648 // Make sure no other provider's env credential pre-empts bedrock.
649- for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY"} {649+ for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_API_KEY", "OPENCODE_API_KEY"} {
650 t.Setenv(k, "")650 t.Setenv(k, "")
651 }651 }
652 652
M packages/agent/modes/auto_compact_test.go +166 -14
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "errors"
45 "testing"
56
67 "github.com/bnema/zut/packages/provider"
@@ -33,54 +34,205 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
3334
3435 func TestShouldAutoContinueAfterCompaction(t *testing.T) {
3536 tests := []struct {
36- name string
37- msgs []provider.Message
38- want bool
37+ name string
38+ origin compactContinuationOrigin
39+ statusActive bool
40+ stop provider.StopReason
41+ turnErr error
42+ msgs []provider.Message
43+ want compactContinuationReason
3944 }{
4045 {
41- name: "completed text answer",
46+ name: "completed text answer",
47+ origin: compactOriginAfterTurnThreshold,
48+ stop: provider.StopEnd,
4249 msgs: []provider.Message{{
4350 Role: provider.RoleAssistant,
4451 Content: []provider.Content{provider.TextBlock{Text: "done"}},
4552 }},
46- want: false,
53+ want: compactContinuationNone,
4754 },
4855 {
49- name: "reasoning only",
56+ name: "reasoning only is structural",
57+ origin: compactOriginAfterTurnThreshold,
58+ stop: provider.StopEnd,
5059 msgs: []provider.Message{{
5160 Role: provider.RoleAssistant,
5261 Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
5362 }},
54- want: true,
63+ want: compactContinuationStructuralTail,
5564 },
5665 {
57- name: "tool call",
66+ name: "tool call is structural",
67+ origin: compactOriginAfterTurnThreshold,
68+ stop: provider.StopEnd,
5869 msgs: []provider.Message{{
5970 Role: provider.RoleAssistant,
6071 Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},
6172 }},
62- want: true,
73+ want: compactContinuationStructuralTail,
6374 },
6475 {
65- name: "user tail",
76+ name: "user tail is structural",
77+ origin: compactOriginAfterTurnThreshold,
78+ stop: provider.StopEnd,
6679 msgs: []provider.Message{{
6780 Role: provider.RoleUser,
6881 Content: []provider.Content{provider.TextBlock{Text: "continue"}},
6982 }},
70- want: true,
83+ want: compactContinuationStructuralTail,
84+ },
85+ {
86+ name: "next will inspect is status rescue",
87+ origin: compactOriginAfterTurnThreshold,
88+ stop: provider.StopEnd,
89+ msgs: []provider.Message{{
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
92+ }},
93+ want: compactContinuationStatusRescue,
94+ },
95+ {
96+ name: "ill now run tests is status rescue",
97+ origin: compactOriginAfterTurnThreshold,
98+ stop: provider.StopEnd,
99+ msgs: []provider.Message{{
100+ Role: provider.RoleAssistant,
101+ Content: []provider.Content{provider.TextBlock{Text: "I’ll now run the targeted tests."}},
102+ }},
103+ want: compactContinuationStatusRescue,
104+ },
105+ {
106+ name: "need to continue at sentence end is status rescue",
107+ origin: compactOriginAfterTurnThreshold,
108+ stop: provider.StopEnd,
109+ msgs: []provider.Message{{
110+ Role: provider.RoleAssistant,
111+ Content: []provider.Content{provider.TextBlock{Text: "I need to continue."}},
112+ }},
113+ want: compactContinuationStatusRescue,
114+ },
115+ {
116+ name: "embedded phrase is not status rescue",
117+ origin: compactOriginAfterTurnThreshold,
118+ stop: provider.StopEnd,
119+ msgs: []provider.Message{{
120+ Role: provider.RoleAssistant,
121+ Content: []provider.Content{provider.TextBlock{Text: "The mini will now absorb this conceptual example."}},
122+ }},
123+ want: compactContinuationNone,
124+ },
125+ {
126+ name: "generic next step prose is not status rescue",
127+ origin: compactOriginAfterTurnThreshold,
128+ stop: provider.StopEnd,
129+ msgs: []provider.Message{{
130+ Role: provider.RoleAssistant,
131+ Content: []provider.Content{provider.TextBlock{Text: "The next step in this algorithm is normalization."}},
132+ }},
133+ want: compactContinuationNone,
134+ },
135+ {
136+ name: "empty",
137+ origin: compactOriginAfterTurnThreshold,
138+ stop: provider.StopEnd,
139+ want: compactContinuationNone,
140+ },
141+ {
142+ name: "aborted status is not rescue",
143+ origin: compactOriginAfterTurnThreshold,
144+ stop: provider.StopAborted,
145+ msgs: []provider.Message{{
146+ Role: provider.RoleAssistant,
147+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
148+ }},
149+ want: compactContinuationNone,
150+ },
151+ {
152+ name: "error status is not rescue",
153+ origin: compactOriginAfterTurnThreshold,
154+ stop: provider.StopEnd,
155+ turnErr: errors.New("provider failed"),
156+ msgs: []provider.Message{{
157+ Role: provider.RoleAssistant,
158+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
159+ }},
160+ want: compactContinuationNone,
161+ },
162+ {
163+ name: "manual does not admit initial status rescue",
164+ origin: compactOriginManual,
165+ stop: provider.StopEnd,
166+ msgs: []provider.Message{{
167+ Role: provider.RoleAssistant,
168+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
169+ }},
170+ want: compactContinuationNone,
171+ },
172+ {
173+ name: "pre-turn does not admit initial status rescue",
174+ origin: compactOriginPreTurnThreshold,
175+ stop: provider.StopEnd,
176+ msgs: []provider.Message{{
177+ Role: provider.RoleAssistant,
178+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
179+ }},
180+ want: compactContinuationNone,
181+ },
182+ {
183+ name: "recovery does not admit initial status rescue",
184+ origin: compactOriginRecovery,
185+ stop: provider.StopEnd,
186+ msgs: []provider.Message{{
187+ Role: provider.RoleAssistant,
188+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
189+ }},
190+ want: compactContinuationNone,
191+ },
192+ {
193+ name: "active rescue may classify its own follow-up",
194+ origin: compactOriginManual,
195+ statusActive: true,
196+ stop: provider.StopEnd,
197+ msgs: []provider.Message{{
198+ Role: provider.RoleAssistant,
199+ Content: []provider.Content{provider.TextBlock{Text: "Then I will inspect the remaining call sites."}},
200+ }},
201+ want: compactContinuationStatusRescue,
71202 },
72- {name: "empty", want: false},
73203 }
74204
75205 for _, tt := range tests {
76206 t.Run(tt.name, func(t *testing.T) {
77- if got := shouldAutoContinueAfterCompaction(tt.msgs); got != tt.want {
78- t.Fatalf("shouldAutoContinueAfterCompaction() = %t, want %t", got, tt.want)
207+ if got := classifyCompactionContinuation(tt.origin, tt.statusActive, tt.stop, tt.turnErr, tt.msgs); got != tt.want {
208+ t.Fatalf("classifyCompactionContinuation() = %d, want %d", got, tt.want)
79209 }
80210 })
81211 }
82212 }
83213
214+func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T) {
215+ hidden := provider.Message{
216+ Role: provider.RoleUser,
217+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
218+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
219+ }
220+ msgs := []provider.Message{
221+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "visible"}}},
222+ hidden,
223+ }
224+ if !isHiddenTranscriptMessage(hidden) {
225+ t.Fatal("auto-compaction continuation should be hidden from transcript views")
226+ }
227+ filtered := filterHiddenTranscriptMessages(msgs)
228+ if len(filtered) != 1 || filtered[0].Content[0].(provider.TextBlock).Text != "visible" {
229+ t.Fatalf("filtered transcript = %#v, want only visible message", filtered)
230+ }
231+ if got := hidden.Meta[autoCompactContinueMetaKey]; got != "true" {
232+ t.Fatalf("persisted continuation metadata = %q, want true", got)
233+ }
234+}
235+
84236 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
85237 tests := []struct {
86238 name string
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "errors"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
@@ -33,54 +34,205 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
33 34
34 func TestShouldAutoContinueAfterCompaction(t *testing.T) {35 func TestShouldAutoContinueAfterCompaction(t *testing.T) {
35 tests := []struct {36 tests := []struct {
36- name string37+ name string
37- msgs []provider.Message38+ origin compactContinuationOrigin
38- want bool39+ statusActive bool
40+ stop provider.StopReason
41+ turnErr error
42+ msgs []provider.Message
43+ want compactContinuationReason
39 }{44 }{
40 {45 {
41- name: "completed text answer",46+ name: "completed text answer",
47+ origin: compactOriginAfterTurnThreshold,
48+ stop: provider.StopEnd,
42 msgs: []provider.Message{{49 msgs: []provider.Message{{
43 Role: provider.RoleAssistant,50 Role: provider.RoleAssistant,
44 Content: []provider.Content{provider.TextBlock{Text: "done"}},51 Content: []provider.Content{provider.TextBlock{Text: "done"}},
45 }},52 }},
46- want: false,53+ want: compactContinuationNone,
47 },54 },
48 {55 {
49- name: "reasoning only",56+ name: "reasoning only is structural",
57+ origin: compactOriginAfterTurnThreshold,
58+ stop: provider.StopEnd,
50 msgs: []provider.Message{{59 msgs: []provider.Message{{
51 Role: provider.RoleAssistant,60 Role: provider.RoleAssistant,
52 Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},61 Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
53 }},62 }},
54- want: true,63+ want: compactContinuationStructuralTail,
55 },64 },
56 {65 {
57- name: "tool call",66+ name: "tool call is structural",
67+ origin: compactOriginAfterTurnThreshold,
68+ stop: provider.StopEnd,
58 msgs: []provider.Message{{69 msgs: []provider.Message{{
59 Role: provider.RoleAssistant,70 Role: provider.RoleAssistant,
60 Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},71 Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},
61 }},72 }},
62- want: true,73+ want: compactContinuationStructuralTail,
63 },74 },
64 {75 {
65- name: "user tail",76+ name: "user tail is structural",
77+ origin: compactOriginAfterTurnThreshold,
78+ stop: provider.StopEnd,
66 msgs: []provider.Message{{79 msgs: []provider.Message{{
67 Role: provider.RoleUser,80 Role: provider.RoleUser,
68 Content: []provider.Content{provider.TextBlock{Text: "continue"}},81 Content: []provider.Content{provider.TextBlock{Text: "continue"}},
69 }},82 }},
70- want: true,83+ want: compactContinuationStructuralTail,
84+ },
85+ {
86+ name: "next will inspect is status rescue",
87+ origin: compactOriginAfterTurnThreshold,
88+ stop: provider.StopEnd,
89+ msgs: []provider.Message{{
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
92+ }},
93+ want: compactContinuationStatusRescue,
94+ },
95+ {
96+ name: "ill now run tests is status rescue",
97+ origin: compactOriginAfterTurnThreshold,
98+ stop: provider.StopEnd,
99+ msgs: []provider.Message{{
100+ Role: provider.RoleAssistant,
101+ Content: []provider.Content{provider.TextBlock{Text: "I’ll now run the targeted tests."}},
102+ }},
103+ want: compactContinuationStatusRescue,
104+ },
105+ {
106+ name: "need to continue at sentence end is status rescue",
107+ origin: compactOriginAfterTurnThreshold,
108+ stop: provider.StopEnd,
109+ msgs: []provider.Message{{
110+ Role: provider.RoleAssistant,
111+ Content: []provider.Content{provider.TextBlock{Text: "I need to continue."}},
112+ }},
113+ want: compactContinuationStatusRescue,
114+ },
115+ {
116+ name: "embedded phrase is not status rescue",
117+ origin: compactOriginAfterTurnThreshold,
118+ stop: provider.StopEnd,
119+ msgs: []provider.Message{{
120+ Role: provider.RoleAssistant,
121+ Content: []provider.Content{provider.TextBlock{Text: "The mini will now absorb this conceptual example."}},
122+ }},
123+ want: compactContinuationNone,
124+ },
125+ {
126+ name: "generic next step prose is not status rescue",
127+ origin: compactOriginAfterTurnThreshold,
128+ stop: provider.StopEnd,
129+ msgs: []provider.Message{{
130+ Role: provider.RoleAssistant,
131+ Content: []provider.Content{provider.TextBlock{Text: "The next step in this algorithm is normalization."}},
132+ }},
133+ want: compactContinuationNone,
134+ },
135+ {
136+ name: "empty",
137+ origin: compactOriginAfterTurnThreshold,
138+ stop: provider.StopEnd,
139+ want: compactContinuationNone,
140+ },
141+ {
142+ name: "aborted status is not rescue",
143+ origin: compactOriginAfterTurnThreshold,
144+ stop: provider.StopAborted,
145+ msgs: []provider.Message{{
146+ Role: provider.RoleAssistant,
147+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
148+ }},
149+ want: compactContinuationNone,
150+ },
151+ {
152+ name: "error status is not rescue",
153+ origin: compactOriginAfterTurnThreshold,
154+ stop: provider.StopEnd,
155+ turnErr: errors.New("provider failed"),
156+ msgs: []provider.Message{{
157+ Role: provider.RoleAssistant,
158+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
159+ }},
160+ want: compactContinuationNone,
161+ },
162+ {
163+ name: "manual does not admit initial status rescue",
164+ origin: compactOriginManual,
165+ stop: provider.StopEnd,
166+ msgs: []provider.Message{{
167+ Role: provider.RoleAssistant,
168+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
169+ }},
170+ want: compactContinuationNone,
171+ },
172+ {
173+ name: "pre-turn does not admit initial status rescue",
174+ origin: compactOriginPreTurnThreshold,
175+ stop: provider.StopEnd,
176+ msgs: []provider.Message{{
177+ Role: provider.RoleAssistant,
178+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
179+ }},
180+ want: compactContinuationNone,
181+ },
182+ {
183+ name: "recovery does not admit initial status rescue",
184+ origin: compactOriginRecovery,
185+ stop: provider.StopEnd,
186+ msgs: []provider.Message{{
187+ Role: provider.RoleAssistant,
188+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
189+ }},
190+ want: compactContinuationNone,
191+ },
192+ {
193+ name: "active rescue may classify its own follow-up",
194+ origin: compactOriginManual,
195+ statusActive: true,
196+ stop: provider.StopEnd,
197+ msgs: []provider.Message{{
198+ Role: provider.RoleAssistant,
199+ Content: []provider.Content{provider.TextBlock{Text: "Then I will inspect the remaining call sites."}},
200+ }},
201+ want: compactContinuationStatusRescue,
71 },202 },
72- {name: "empty", want: false},
73 }203 }
74 204
75 for _, tt := range tests {205 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {206 t.Run(tt.name, func(t *testing.T) {
77- if got := shouldAutoContinueAfterCompaction(tt.msgs); got != tt.want {207+ if got := classifyCompactionContinuation(tt.origin, tt.statusActive, tt.stop, tt.turnErr, tt.msgs); got != tt.want {
78- t.Fatalf("shouldAutoContinueAfterCompaction() = %t, want %t", got, tt.want)208+ t.Fatalf("classifyCompactionContinuation() = %d, want %d", got, tt.want)
79 }209 }
80 })210 })
81 }211 }
82 }212 }
83 213
214+func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T) {
215+ hidden := provider.Message{
216+ Role: provider.RoleUser,
217+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
218+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
219+ }
220+ msgs := []provider.Message{
221+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "visible"}}},
222+ hidden,
223+ }
224+ if !isHiddenTranscriptMessage(hidden) {
225+ t.Fatal("auto-compaction continuation should be hidden from transcript views")
226+ }
227+ filtered := filterHiddenTranscriptMessages(msgs)
228+ if len(filtered) != 1 || filtered[0].Content[0].(provider.TextBlock).Text != "visible" {
229+ t.Fatalf("filtered transcript = %#v, want only visible message", filtered)
230+ }
231+ if got := hidden.Meta[autoCompactContinueMetaKey]; got != "true" {
232+ t.Fatalf("persisted continuation metadata = %q, want true", got)
233+ }
234+}
235+
84 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {236 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
85 tests := []struct {237 tests := []struct {
86 name string238 name string
M packages/agent/modes/compact_queue_test.go +318 -2
@@ -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, false)
156+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
157157 select {
158158 case <-client.compactionStarted:
159159 case <-time.After(2 * time.Second):
@@ -180,6 +180,9 @@ type thresholdAutoCompactClient struct {
180180 followUpRequest chan provider.Request
181181 firstStop provider.StopReason
182182 firstContent []provider.Content
183+ followUpContents []string
184+ blockFollowUpCall int
185+ releaseFollowUp <-chan struct{}
183186
184187 mu sync.Mutex
185188 calls int
@@ -227,13 +230,26 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
227230 default:
228231 c.mu.Lock()
229232 c.followUps++
233+ followUp := call - 2
234+ text := "continued"
235+ if followUp > 0 && followUp <= len(c.followUpContents) {
236+ text = c.followUpContents[followUp-1]
237+ }
230238 c.mu.Unlock()
231239 c.followUpRequest <- req
240+ if c.blockFollowUpCall == call {
241+ select {
242+ case <-ctx.Done():
243+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
244+ return
245+ case <-c.releaseFollowUp:
246+ }
247+ }
232248 out <- provider.EventDone{
233249 Stop: provider.StopEnd,
234250 Message: provider.Message{
235251 Role: provider.RoleAssistant,
236- Content: []provider.Content{provider.TextBlock{Text: "continued"}},
252+ Content: []provider.Content{provider.TextBlock{Text: text}},
237253 },
238254 }
239255 }
@@ -275,6 +291,306 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
275291 }
276292 }
277293
294+func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
295+ client := &thresholdAutoCompactClient{
296+ compactionStarted: make(chan struct{}),
297+ releaseCompaction: make(chan struct{}),
298+ followUpRequest: make(chan provider.Request, 1),
299+ firstContent: []provider.Content{
300+ provider.TextBlock{Text: "I have completed this pass. Next I will inspect the remaining call sites."},
301+ },
302+ }
303+ agent := core.NewAgent(client, "test-model", "", nil)
304+ threshold := 70
305+ interactive := NewInteractive(InteractiveConfig{
306+ Agent: agent,
307+ Provider: "anthropic",
308+ Model: "claude-sonnet-4-5-20250929",
309+ AutoCompactThreshold: &threshold,
310+ })
311+ interactive.runCtx = context.Background()
312+
313+ interactive.startTurn(context.Background(), "initial request")
314+ select {
315+ case <-client.compactionStarted:
316+ case <-time.After(2 * time.Second):
317+ t.Fatal("threshold auto-compaction did not start")
318+ }
319+ close(client.releaseCompaction)
320+
321+ select {
322+ case req := <-client.followUpRequest:
323+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
324+ t.Fatalf("status rescue request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
325+ }
326+ case <-time.After(2 * time.Second):
327+ t.Fatal("status rescue continuation did not start")
328+ }
329+ waitInteractiveIdle(t, interactive)
330+
331+ client.mu.Lock()
332+ calls := client.calls
333+ client.mu.Unlock()
334+ if calls != 3 {
335+ t.Fatalf("provider called %d times, want initial request, compaction, and one status rescue", calls)
336+ }
337+}
338+
339+func TestThresholdAutoCompactionBoundsSecondStatusRescue(t *testing.T) {
340+ client := &thresholdAutoCompactClient{
341+ compactionStarted: make(chan struct{}),
342+ releaseCompaction: make(chan struct{}),
343+ followUpRequest: make(chan provider.Request, 2),
344+ firstContent: []provider.Content{
345+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
346+ },
347+ followUpContents: []string{
348+ "Then I will run the targeted tests.",
349+ "done",
350+ },
351+ }
352+ agent := core.NewAgent(client, "test-model", "", nil)
353+ threshold := 70
354+ interactive := NewInteractive(InteractiveConfig{
355+ Agent: agent,
356+ Provider: "anthropic",
357+ Model: "claude-sonnet-4-5-20250929",
358+ AutoCompactThreshold: &threshold,
359+ })
360+ interactive.runCtx = context.Background()
361+
362+ interactive.startTurn(context.Background(), "initial request")
363+ select {
364+ case <-client.compactionStarted:
365+ case <-time.After(2 * time.Second):
366+ t.Fatal("threshold auto-compaction did not start")
367+ }
368+ close(client.releaseCompaction)
369+
370+ for attempt := 1; attempt <= 2; attempt++ {
371+ select {
372+ case req := <-client.followUpRequest:
373+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
374+ t.Fatalf("status rescue %d contains auto-continue prompt %d times, want %d: %#v", attempt, got, attempt, req.Messages)
375+ }
376+ case <-time.After(2 * time.Second):
377+ t.Fatalf("bounded status rescue %d did not start", attempt)
378+ }
379+ }
380+ waitInteractiveIdle(t, interactive)
381+
382+ client.mu.Lock()
383+ calls, followUps := client.calls, client.followUps
384+ client.mu.Unlock()
385+ if calls != 4 || followUps != 2 {
386+ t.Fatalf("provider calls/follow-ups = %d/%d, want 4/2", calls, followUps)
387+ }
388+}
389+
390+func TestThresholdAutoCompactionStopsAfterStatusRescueCompletion(t *testing.T) {
391+ client := &thresholdAutoCompactClient{
392+ compactionStarted: make(chan struct{}),
393+ releaseCompaction: make(chan struct{}),
394+ followUpRequest: make(chan provider.Request, 2),
395+ firstContent: []provider.Content{
396+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
397+ },
398+ followUpContents: []string{"Then I will run the targeted tests.", "All required work is complete."},
399+ }
400+ agent := core.NewAgent(client, "test-model", "", nil)
401+ threshold := 70
402+ interactive := NewInteractive(InteractiveConfig{
403+ Agent: agent,
404+ Provider: "anthropic",
405+ Model: "claude-sonnet-4-5-20250929",
406+ AutoCompactThreshold: &threshold,
407+ })
408+ interactive.runCtx = context.Background()
409+
410+ interactive.startTurn(context.Background(), "initial request")
411+ select {
412+ case <-client.compactionStarted:
413+ case <-time.After(2 * time.Second):
414+ t.Fatal("threshold auto-compaction did not start")
415+ }
416+ close(client.releaseCompaction)
417+ for attempt := 1; attempt <= 2; attempt++ {
418+ select {
419+ case <-client.followUpRequest:
420+ case <-time.After(2 * time.Second):
421+ t.Fatalf("status rescue %d did not start", attempt)
422+ }
423+ }
424+ waitInteractiveIdle(t, interactive)
425+
426+ client.mu.Lock()
427+ calls := client.calls
428+ client.mu.Unlock()
429+ if calls != 4 {
430+ t.Fatalf("provider called %d times after completion, want 4", calls)
431+ }
432+ select {
433+ case <-client.followUpRequest:
434+ t.Fatal("completion after bounded rescue started an extra request")
435+ default:
436+ }
437+}
438+
439+func TestThresholdStatusRescueDefersToNewerUserPrompt(t *testing.T) {
440+ client := &thresholdAutoCompactClient{
441+ compactionStarted: make(chan struct{}),
442+ releaseCompaction: make(chan struct{}),
443+ followUpRequest: make(chan provider.Request, 1),
444+ firstContent: []provider.Content{
445+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
446+ },
447+ }
448+ agent := core.NewAgent(client, "test-model", "", nil)
449+ threshold := 70
450+ interactive := NewInteractive(InteractiveConfig{
451+ Agent: agent,
452+ Provider: "anthropic",
453+ Model: "claude-sonnet-4-5-20250929",
454+ AutoCompactThreshold: &threshold,
455+ })
456+ interactive.runCtx = context.Background()
457+
458+ interactive.startTurn(context.Background(), "initial request")
459+ select {
460+ case <-client.compactionStarted:
461+ case <-time.After(2 * time.Second):
462+ t.Fatal("threshold auto-compaction did not start")
463+ }
464+ interactive.SubmitOrQueue("newer user request", nil)
465+ close(client.releaseCompaction)
466+
467+ select {
468+ case req := <-client.followUpRequest:
469+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
470+ t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
471+ }
472+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 0 {
473+ t.Fatalf("superseded status rescue count = %d, want 0: %#v", got, req.Messages)
474+ }
475+ case <-time.After(2 * time.Second):
476+ t.Fatal("newer user request did not run")
477+ }
478+ waitInteractiveIdle(t, interactive)
479+}
480+
481+func TestThresholdStatusRescueQueuedUserCancelsFollowUpHandoff(t *testing.T) {
482+ releaseFollowUp := make(chan struct{})
483+ client := &thresholdAutoCompactClient{
484+ compactionStarted: make(chan struct{}),
485+ releaseCompaction: make(chan struct{}),
486+ followUpRequest: make(chan provider.Request, 3),
487+ firstContent: []provider.Content{
488+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
489+ },
490+ followUpContents: []string{
491+ "Then I will run the targeted tests.",
492+ "Next I will inspect the user's newly requested work.",
493+ },
494+ blockFollowUpCall: 3,
495+ releaseFollowUp: releaseFollowUp,
496+ }
497+ agent := core.NewAgent(client, "test-model", "", nil)
498+ threshold := 70
499+ interactive := NewInteractive(InteractiveConfig{
500+ Agent: agent,
501+ Provider: "anthropic",
502+ Model: "claude-sonnet-4-5-20250929",
503+ AutoCompactThreshold: &threshold,
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurn(context.Background(), "initial request")
508+ select {
509+ case <-client.compactionStarted:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("threshold auto-compaction did not start")
512+ }
513+ close(client.releaseCompaction)
514+
515+ select {
516+ case req := <-client.followUpRequest:
517+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
518+ t.Fatalf("first status rescue contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
519+ }
520+ case <-time.After(2 * time.Second):
521+ t.Fatal("first status rescue did not start")
522+ }
523+
524+ interactive.SubmitOrQueue("newer user request", nil)
525+ close(releaseFollowUp)
526+
527+ select {
528+ case req := <-client.followUpRequest:
529+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
530+ t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
531+ }
532+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
533+ t.Fatalf("superseded follow-up rescue count = %d, want 1 existing prompt: %#v", got, req.Messages)
534+ }
535+ case <-time.After(2 * time.Second):
536+ t.Fatal("newer user request did not run after the status rescue")
537+ }
538+ waitInteractiveIdle(t, interactive)
539+
540+ client.mu.Lock()
541+ calls := client.calls
542+ client.mu.Unlock()
543+ if calls != 4 {
544+ t.Fatalf("provider called %d times, want initial request, compaction, status rescue, and newer user request", calls)
545+ }
546+ select {
547+ case req := <-client.followUpRequest:
548+ t.Fatalf("superseded handoff started an extra continuation: %#v", req.Messages)
549+ default:
550+ }
551+}
552+
553+func TestThresholdAutoCompactionDoesNotRescueClearText(t *testing.T) {
554+ client := &thresholdAutoCompactClient{
555+ compactionStarted: make(chan struct{}),
556+ releaseCompaction: make(chan struct{}),
557+ followUpRequest: make(chan provider.Request, 1),
558+ firstContent: []provider.Content{
559+ provider.TextBlock{Text: "All required work is complete."},
560+ },
561+ }
562+ agent := core.NewAgent(client, "test-model", "", nil)
563+ threshold := 70
564+ interactive := NewInteractive(InteractiveConfig{
565+ Agent: agent,
566+ Provider: "anthropic",
567+ Model: "claude-sonnet-4-5-20250929",
568+ AutoCompactThreshold: &threshold,
569+ })
570+ interactive.runCtx = context.Background()
571+
572+ interactive.startTurn(context.Background(), "initial request")
573+ select {
574+ case <-client.compactionStarted:
575+ case <-time.After(2 * time.Second):
576+ t.Fatal("threshold auto-compaction did not start")
577+ }
578+ close(client.releaseCompaction)
579+ waitInteractiveIdle(t, interactive)
580+
581+ client.mu.Lock()
582+ calls := client.calls
583+ client.mu.Unlock()
584+ if calls != 2 {
585+ t.Fatalf("provider called %d times for clear text, want initial request and compaction", calls)
586+ }
587+ select {
588+ case req := <-client.followUpRequest:
589+ t.Fatalf("clear text unexpectedly started follow-up: %#v", req.Messages)
590+ default:
591+ }
592+}
593+
278594 func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279595 client := &thresholdAutoCompactClient{
280596 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, false)156+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
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):
@@ -180,6 +180,9 @@ type thresholdAutoCompactClient struct {
180 followUpRequest chan provider.Request180 followUpRequest chan provider.Request
181 firstStop provider.StopReason181 firstStop provider.StopReason
182 firstContent []provider.Content182 firstContent []provider.Content
183+ followUpContents []string
184+ blockFollowUpCall int
185+ releaseFollowUp <-chan struct{}
183 186
184 mu sync.Mutex187 mu sync.Mutex
185 calls int188 calls int
@@ -227,13 +230,26 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
227 default:230 default:
228 c.mu.Lock()231 c.mu.Lock()
229 c.followUps++232 c.followUps++
233+ followUp := call - 2
234+ text := "continued"
235+ if followUp > 0 && followUp <= len(c.followUpContents) {
236+ text = c.followUpContents[followUp-1]
237+ }
230 c.mu.Unlock()238 c.mu.Unlock()
231 c.followUpRequest <- req239 c.followUpRequest <- req
240+ if c.blockFollowUpCall == call {
241+ select {
242+ case <-ctx.Done():
243+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
244+ return
245+ case <-c.releaseFollowUp:
246+ }
247+ }
232 out <- provider.EventDone{248 out <- provider.EventDone{
233 Stop: provider.StopEnd,249 Stop: provider.StopEnd,
234 Message: provider.Message{250 Message: provider.Message{
235 Role: provider.RoleAssistant,251 Role: provider.RoleAssistant,
236- Content: []provider.Content{provider.TextBlock{Text: "continued"}},252+ Content: []provider.Content{provider.TextBlock{Text: text}},
237 },253 },
238 }254 }
239 }255 }
@@ -275,6 +291,306 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
275 }291 }
276 }292 }
277 293
294+func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
295+ client := &thresholdAutoCompactClient{
296+ compactionStarted: make(chan struct{}),
297+ releaseCompaction: make(chan struct{}),
298+ followUpRequest: make(chan provider.Request, 1),
299+ firstContent: []provider.Content{
300+ provider.TextBlock{Text: "I have completed this pass. Next I will inspect the remaining call sites."},
301+ },
302+ }
303+ agent := core.NewAgent(client, "test-model", "", nil)
304+ threshold := 70
305+ interactive := NewInteractive(InteractiveConfig{
306+ Agent: agent,
307+ Provider: "anthropic",
308+ Model: "claude-sonnet-4-5-20250929",
309+ AutoCompactThreshold: &threshold,
310+ })
311+ interactive.runCtx = context.Background()
312+
313+ interactive.startTurn(context.Background(), "initial request")
314+ select {
315+ case <-client.compactionStarted:
316+ case <-time.After(2 * time.Second):
317+ t.Fatal("threshold auto-compaction did not start")
318+ }
319+ close(client.releaseCompaction)
320+
321+ select {
322+ case req := <-client.followUpRequest:
323+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
324+ t.Fatalf("status rescue request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
325+ }
326+ case <-time.After(2 * time.Second):
327+ t.Fatal("status rescue continuation did not start")
328+ }
329+ waitInteractiveIdle(t, interactive)
330+
331+ client.mu.Lock()
332+ calls := client.calls
333+ client.mu.Unlock()
334+ if calls != 3 {
335+ t.Fatalf("provider called %d times, want initial request, compaction, and one status rescue", calls)
336+ }
337+}
338+
339+func TestThresholdAutoCompactionBoundsSecondStatusRescue(t *testing.T) {
340+ client := &thresholdAutoCompactClient{
341+ compactionStarted: make(chan struct{}),
342+ releaseCompaction: make(chan struct{}),
343+ followUpRequest: make(chan provider.Request, 2),
344+ firstContent: []provider.Content{
345+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
346+ },
347+ followUpContents: []string{
348+ "Then I will run the targeted tests.",
349+ "done",
350+ },
351+ }
352+ agent := core.NewAgent(client, "test-model", "", nil)
353+ threshold := 70
354+ interactive := NewInteractive(InteractiveConfig{
355+ Agent: agent,
356+ Provider: "anthropic",
357+ Model: "claude-sonnet-4-5-20250929",
358+ AutoCompactThreshold: &threshold,
359+ })
360+ interactive.runCtx = context.Background()
361+
362+ interactive.startTurn(context.Background(), "initial request")
363+ select {
364+ case <-client.compactionStarted:
365+ case <-time.After(2 * time.Second):
366+ t.Fatal("threshold auto-compaction did not start")
367+ }
368+ close(client.releaseCompaction)
369+
370+ for attempt := 1; attempt <= 2; attempt++ {
371+ select {
372+ case req := <-client.followUpRequest:
373+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
374+ t.Fatalf("status rescue %d contains auto-continue prompt %d times, want %d: %#v", attempt, got, attempt, req.Messages)
375+ }
376+ case <-time.After(2 * time.Second):
377+ t.Fatalf("bounded status rescue %d did not start", attempt)
378+ }
379+ }
380+ waitInteractiveIdle(t, interactive)
381+
382+ client.mu.Lock()
383+ calls, followUps := client.calls, client.followUps
384+ client.mu.Unlock()
385+ if calls != 4 || followUps != 2 {
386+ t.Fatalf("provider calls/follow-ups = %d/%d, want 4/2", calls, followUps)
387+ }
388+}
389+
390+func TestThresholdAutoCompactionStopsAfterStatusRescueCompletion(t *testing.T) {
391+ client := &thresholdAutoCompactClient{
392+ compactionStarted: make(chan struct{}),
393+ releaseCompaction: make(chan struct{}),
394+ followUpRequest: make(chan provider.Request, 2),
395+ firstContent: []provider.Content{
396+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
397+ },
398+ followUpContents: []string{"Then I will run the targeted tests.", "All required work is complete."},
399+ }
400+ agent := core.NewAgent(client, "test-model", "", nil)
401+ threshold := 70
402+ interactive := NewInteractive(InteractiveConfig{
403+ Agent: agent,
404+ Provider: "anthropic",
405+ Model: "claude-sonnet-4-5-20250929",
406+ AutoCompactThreshold: &threshold,
407+ })
408+ interactive.runCtx = context.Background()
409+
410+ interactive.startTurn(context.Background(), "initial request")
411+ select {
412+ case <-client.compactionStarted:
413+ case <-time.After(2 * time.Second):
414+ t.Fatal("threshold auto-compaction did not start")
415+ }
416+ close(client.releaseCompaction)
417+ for attempt := 1; attempt <= 2; attempt++ {
418+ select {
419+ case <-client.followUpRequest:
420+ case <-time.After(2 * time.Second):
421+ t.Fatalf("status rescue %d did not start", attempt)
422+ }
423+ }
424+ waitInteractiveIdle(t, interactive)
425+
426+ client.mu.Lock()
427+ calls := client.calls
428+ client.mu.Unlock()
429+ if calls != 4 {
430+ t.Fatalf("provider called %d times after completion, want 4", calls)
431+ }
432+ select {
433+ case <-client.followUpRequest:
434+ t.Fatal("completion after bounded rescue started an extra request")
435+ default:
436+ }
437+}
438+
439+func TestThresholdStatusRescueDefersToNewerUserPrompt(t *testing.T) {
440+ client := &thresholdAutoCompactClient{
441+ compactionStarted: make(chan struct{}),
442+ releaseCompaction: make(chan struct{}),
443+ followUpRequest: make(chan provider.Request, 1),
444+ firstContent: []provider.Content{
445+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
446+ },
447+ }
448+ agent := core.NewAgent(client, "test-model", "", nil)
449+ threshold := 70
450+ interactive := NewInteractive(InteractiveConfig{
451+ Agent: agent,
452+ Provider: "anthropic",
453+ Model: "claude-sonnet-4-5-20250929",
454+ AutoCompactThreshold: &threshold,
455+ })
456+ interactive.runCtx = context.Background()
457+
458+ interactive.startTurn(context.Background(), "initial request")
459+ select {
460+ case <-client.compactionStarted:
461+ case <-time.After(2 * time.Second):
462+ t.Fatal("threshold auto-compaction did not start")
463+ }
464+ interactive.SubmitOrQueue("newer user request", nil)
465+ close(client.releaseCompaction)
466+
467+ select {
468+ case req := <-client.followUpRequest:
469+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
470+ t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
471+ }
472+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 0 {
473+ t.Fatalf("superseded status rescue count = %d, want 0: %#v", got, req.Messages)
474+ }
475+ case <-time.After(2 * time.Second):
476+ t.Fatal("newer user request did not run")
477+ }
478+ waitInteractiveIdle(t, interactive)
479+}
480+
481+func TestThresholdStatusRescueQueuedUserCancelsFollowUpHandoff(t *testing.T) {
482+ releaseFollowUp := make(chan struct{})
483+ client := &thresholdAutoCompactClient{
484+ compactionStarted: make(chan struct{}),
485+ releaseCompaction: make(chan struct{}),
486+ followUpRequest: make(chan provider.Request, 3),
487+ firstContent: []provider.Content{
488+ provider.TextBlock{Text: "Next I will inspect the remaining call sites."},
489+ },
490+ followUpContents: []string{
491+ "Then I will run the targeted tests.",
492+ "Next I will inspect the user's newly requested work.",
493+ },
494+ blockFollowUpCall: 3,
495+ releaseFollowUp: releaseFollowUp,
496+ }
497+ agent := core.NewAgent(client, "test-model", "", nil)
498+ threshold := 70
499+ interactive := NewInteractive(InteractiveConfig{
500+ Agent: agent,
501+ Provider: "anthropic",
502+ Model: "claude-sonnet-4-5-20250929",
503+ AutoCompactThreshold: &threshold,
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurn(context.Background(), "initial request")
508+ select {
509+ case <-client.compactionStarted:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("threshold auto-compaction did not start")
512+ }
513+ close(client.releaseCompaction)
514+
515+ select {
516+ case req := <-client.followUpRequest:
517+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
518+ t.Fatalf("first status rescue contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
519+ }
520+ case <-time.After(2 * time.Second):
521+ t.Fatal("first status rescue did not start")
522+ }
523+
524+ interactive.SubmitOrQueue("newer user request", nil)
525+ close(releaseFollowUp)
526+
527+ select {
528+ case req := <-client.followUpRequest:
529+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
530+ t.Fatalf("newer request count = %d, want 1: %#v", got, req.Messages)
531+ }
532+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
533+ t.Fatalf("superseded follow-up rescue count = %d, want 1 existing prompt: %#v", got, req.Messages)
534+ }
535+ case <-time.After(2 * time.Second):
536+ t.Fatal("newer user request did not run after the status rescue")
537+ }
538+ waitInteractiveIdle(t, interactive)
539+
540+ client.mu.Lock()
541+ calls := client.calls
542+ client.mu.Unlock()
543+ if calls != 4 {
544+ t.Fatalf("provider called %d times, want initial request, compaction, status rescue, and newer user request", calls)
545+ }
546+ select {
547+ case req := <-client.followUpRequest:
548+ t.Fatalf("superseded handoff started an extra continuation: %#v", req.Messages)
549+ default:
550+ }
551+}
552+
553+func TestThresholdAutoCompactionDoesNotRescueClearText(t *testing.T) {
554+ client := &thresholdAutoCompactClient{
555+ compactionStarted: make(chan struct{}),
556+ releaseCompaction: make(chan struct{}),
557+ followUpRequest: make(chan provider.Request, 1),
558+ firstContent: []provider.Content{
559+ provider.TextBlock{Text: "All required work is complete."},
560+ },
561+ }
562+ agent := core.NewAgent(client, "test-model", "", nil)
563+ threshold := 70
564+ interactive := NewInteractive(InteractiveConfig{
565+ Agent: agent,
566+ Provider: "anthropic",
567+ Model: "claude-sonnet-4-5-20250929",
568+ AutoCompactThreshold: &threshold,
569+ })
570+ interactive.runCtx = context.Background()
571+
572+ interactive.startTurn(context.Background(), "initial request")
573+ select {
574+ case <-client.compactionStarted:
575+ case <-time.After(2 * time.Second):
576+ t.Fatal("threshold auto-compaction did not start")
577+ }
578+ close(client.releaseCompaction)
579+ waitInteractiveIdle(t, interactive)
580+
581+ client.mu.Lock()
582+ calls := client.calls
583+ client.mu.Unlock()
584+ if calls != 2 {
585+ t.Fatalf("provider called %d times for clear text, want initial request and compaction", calls)
586+ }
587+ select {
588+ case req := <-client.followUpRequest:
589+ t.Fatalf("clear text unexpectedly started follow-up: %#v", req.Messages)
590+ default:
591+ }
592+}
593+
278 func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {594 func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279 client := &thresholdAutoCompactClient{595 client := &thresholdAutoCompactClient{
280 compactionStarted: make(chan struct{}),596 compactionStarted: make(chan struct{}),
M packages/agent/modes/interactive.go +279 -28
@@ -14,6 +14,8 @@ import (
1414 "sync"
1515 "sync/atomic"
1616 "time"
17+ "unicode"
18+ "unicode/utf8"
1719
1820 "github.com/bnema/zut/packages/agent/extensions"
1921 "github.com/bnema/zut/packages/agent/extproto"
@@ -497,6 +499,38 @@ type webSearchSettingsStore interface {
497499 SetWebSearchEnabled(enabled bool) error
498500 }
499501
502+type compactContinuationOrigin uint8
503+
504+type compactContinuationRequest struct {
505+ origin compactContinuationOrigin
506+ force bool
507+ lastStop provider.StopReason
508+ turnError error
509+}
510+
511+const (
512+ compactOriginManual compactContinuationOrigin = iota
513+ compactOriginPreTurnThreshold
514+ compactOriginAfterTurnThreshold
515+ compactOriginRecovery
516+)
517+
518+type compactContinuationReason uint8
519+
520+const (
521+ compactContinuationNone compactContinuationReason = iota
522+ compactContinuationStructuralTail
523+ compactContinuationForcedLength
524+ compactContinuationStatusRescue
525+)
526+
527+const maxStatusRescueContinuations = 2
528+
529+type compactContinuationState struct {
530+ reason compactContinuationReason
531+ rescueAttempts int
532+}
533+
500534 type Interactive struct {
501535 cfg InteractiveConfig
502536 view *tui.View
@@ -625,6 +659,11 @@ type Interactive struct {
625659 // condense pass from a regular assistant turn.
626660 autoCompacting bool
627661
662+ // compactContinuation records the private reason and bounded rescue
663+ // budget for the live compaction handoff. It is never persisted or
664+ // inferred from rendered transcript rows.
665+ compactContinuation compactContinuationState
666+
628667 // updateInfo is the result of the async update check. Zero value
629668 // while the check hasn't completed or nothing is available.
630669 updateInfo UpdateInfo
@@ -3044,8 +3083,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
30443083 i.invalidate()
30453084 return false
30463085 }
3047- if i.busy && i.cancelTurn != nil {
3048- i.cancelTurn()
3086+ i.mu.Lock()
3087+ busyCancel := i.busy && i.cancelTurn != nil
3088+ cancelTurn := i.cancelTurn
3089+ if busyCancel {
3090+ i.resetCompactContinuationLocked()
3091+ }
3092+ i.mu.Unlock()
3093+ if busyCancel {
3094+ cancelTurn()
30493095 // If a confirm dialog is pending, refuse it so the agent
30503096 // goroutine unblocks and the context cancellation can
30513097 // actually take effect.
@@ -3358,9 +3404,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
33583404 }
33593405 clearSubmittedInput()
33603406 if ag != nil && !compacting {
3407+ i.mu.Lock()
3408+ i.resetCompactContinuationLocked()
33613409 ag.QueueMessage(text)
3410+ i.mu.Unlock()
33623411 } else {
33633412 i.mu.Lock()
3413+ i.resetCompactContinuationLocked()
33643414 i.queued = append(i.queued, text)
33653415 i.mu.Unlock()
33663416 }
@@ -3864,8 +3914,10 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
38643914 // Compaction uses the host queue because it has no active agent loop
38653915 // to drain Agent.QueueMessage entries.
38663916 if i.agent != nil && !i.compacting {
3917+ i.resetCompactContinuationLocked()
38673918 i.agent.QueueMessage(text)
38683919 } else {
3920+ i.resetCompactContinuationLocked()
38693921 i.queued = append(i.queued, text)
38703922 }
38713923 i.mu.Unlock()
@@ -3893,6 +3945,9 @@ func (i *Interactive) CancelTurn() {
38933945 cancel := i.cancelTurn
38943946 i.mu.Unlock()
38953947 if cancel != nil {
3948+ i.mu.Lock()
3949+ i.resetCompactContinuationLocked()
3950+ i.mu.Unlock()
38963951 cancel()
38973952 i.confirmDialog.CancelAll("turn cancelled")
38983953 }
@@ -5657,6 +5712,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
56575712 i.scrollOffset = 0
56585713 i.extNotes = nil
56595714 i.reloadErrors = nil
5715+ i.resetCompactContinuationLocked()
56605716 i.view.InvalidateRenderCache()
56615717 i.mu.Unlock()
56625718 case "/help":
@@ -5736,7 +5792,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
57365792 case "/skills":
57375793 i.openSkillsDialog()
57385794 case "/compact":
5739- i.runCompact(ctx, false, false)
5795+ i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
57405796 case "/study":
57415797 // Canned prompt that tells the agent to read every file
57425798 // in some target so its later turns have the whole thing
@@ -5756,9 +5812,13 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
57565812 i.mu.Unlock()
57575813 if busy {
57585814 if ag != nil && !compacting {
5815+ i.mu.Lock()
5816+ i.resetCompactContinuationLocked()
57595817 ag.QueueMessage(studyPrompt)
5818+ i.mu.Unlock()
57605819 } else {
57615820 i.mu.Lock()
5821+ i.resetCompactContinuationLocked()
57625822 i.queued = append(i.queued, studyPrompt)
57635823 i.mu.Unlock()
57645824 }
@@ -6059,6 +6119,7 @@ func (i *Interactive) doLogout(target string) {
60596119 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
60606120 }
60616121 i.agent = nil
6122+ i.resetCompactContinuationLocked()
60626123 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
60636124 } else {
60646125 i.statusOK = "logged out of " + strings.Join(providers, ", ")
@@ -6244,6 +6305,9 @@ func (i *Interactive) cancelAndWaitForIdle() {
62446305 return
62456306 }
62466307 if cancel != nil {
6308+ i.mu.Lock()
6309+ i.resetCompactContinuationLocked()
6310+ i.mu.Unlock()
62476311 cancel()
62486312 }
62496313 // ConfirmToolCall waits on its response channel rather than the turn
@@ -6308,8 +6372,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
63086372 // goroutine cannot publish idle and inspect an empty queue between
63096373 // this check and the append.
63106374 if i.compacting {
6375+ i.resetCompactContinuationLocked()
63116376 i.queued = append(i.queued, prompt)
63126377 } else {
6378+ i.resetCompactContinuationLocked()
63136379 i.agent.QueueMessage(prompt)
63146380 }
63156381 i.mu.Unlock()
@@ -6682,7 +6748,13 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
66826748 i.pendingPostCompactNote = ""
66836749 }
66846750
6685-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.`
6751+// resetCompactContinuationLocked clears the private handoff state. The
6752+// caller must hold i.mu.
6753+func (i *Interactive) resetCompactContinuationLocked() {
6754+ i.compactContinuation = compactContinuationState{}
6755+}
6756+
6757+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". Inspect 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. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.`
66866758
66876759 // startAutoCompactContinuation adds an internal user turn that tells the
66886760 // model to resume the task after threshold compaction. Provider requests
@@ -6692,9 +6764,7 @@ const autoCompactContinuationPrompt = `Context was compacted while work was in p
66926764 func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
66936765 i.mu.Lock()
66946766 ag := i.agent
6695- i.mu.Unlock()
66966767 if ag == nil {
6697- i.mu.Lock()
66986768 i.busy = false
66996769 pendingIdleWork := i.takePendingIdleWorkLocked()
67006770 i.mu.Unlock()
@@ -6702,11 +6772,28 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
67026772 i.invalidate()
67036773 return
67046774 }
6705- if ag.QueuedMessageCount() == 0 {
6706- ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6707- autoCompactContinueMetaKey: "true",
6708- })
6775+ // The caller selected the continuation while holding i.mu. Keep that
6776+ // ordering through the hidden append so an explicitly queued user prompt
6777+ // cannot be overtaken by an old-task handoff. Forced truncation is the
6778+ // existing exception: it keeps priority and the queued prompt runs after it.
6779+ if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
6780+ next := i.queued[0]
6781+ i.queued = i.queued[1:]
6782+ i.resetCompactContinuationLocked()
6783+ i.mu.Unlock()
6784+ i.startTurn(parent, next)
6785+ return
67096786 }
6787+ if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788+ i.resetCompactContinuationLocked()
6789+ i.mu.Unlock()
6790+ i.startTurnRequest(parent, "", nil, true, false)
6791+ return
6792+ }
6793+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6794+ autoCompactContinueMetaKey: "true",
6795+ })
6796+ i.mu.Unlock()
67106797 i.startTurnRequest(parent, "", nil, true, false)
67116798 }
67126799
@@ -6717,9 +6804,10 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
67176804 // When auto is true the spinner message is pinned to "condensing
67186805 // history" and the status bar surfaces "(auto)" next to the context
67196806 // percentage so it's obvious the system triggered this, not the user.
6720-// forceAutoContinue tells the post-compact hand-off to resume even if the
6807+// request.force tells the post-compact hand-off to resume even if the
67216808 // transcript tail has visible assistant text, for example after StopLength.
6722-func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {
6809+func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
6810+ auto := request.origin != compactOriginManual
67236811 if i.agent == nil {
67246812 i.mu.Lock()
67256813 i.statusErr = "not logged in. type /login first."
@@ -6739,6 +6827,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
67396827 i.activity.kind = activityCompactingHistory
67406828 }
67416829 i.cancelTurn = cancel
6830+ if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831+ i.resetCompactContinuationLocked()
6832+ }
67426833 i.statusErr = ""
67436834 i.statusOK = ""
67446835 // Do NOT set streamOn: the summary text should not be visible
@@ -6753,7 +6844,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
67536844 // Sink discards deltas — we don't stream the summary to the UI.
67546845 sink := func(delta string) {}
67556846 msgsBefore := i.agent.Messages()
6756- autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))
6847+ i.mu.Lock()
6848+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
6849+ i.mu.Unlock()
6850+ continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
6851+ if request.force {
6852+ continuationReason = compactContinuationForcedLength
6853+ }
67576854 // Keep the usual recent tail when possible, but never let it cover
67586855 // the whole transcript. A short session can still be at 70–90% of
67596856 // a model's window because one prompt or tool result is large; the
@@ -6794,6 +6891,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
67946891 }
67956892 i.queued = nil // drop queue on cancel
67966893 i.clearPendingCompactTurnLocked()
6894+ i.resetCompactContinuationLocked()
67976895 if i.agent != nil {
67986896 i.agent.DrainQueuedMessages()
67996897 }
@@ -6802,6 +6900,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
68026900 i.statusOK = ""
68036901 i.queued = nil // drop queue on error
68046902 i.clearPendingCompactTurnLocked()
6903+ i.resetCompactContinuationLocked()
68056904 if i.agent != nil {
68066905 i.agent.DrainQueuedMessages()
68076906 }
@@ -6839,17 +6938,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
68396938 i.pendingCompactImages = nil
68406939 i.hasPendingCompactPrompt = false
68416940 hasNext = true
6842- case forceAutoContinue && autoContinue:
6941+ case continuationReason == compactContinuationForcedLength:
6942+ // Forced truncated-output continuation keeps its existing
6943+ // priority over an explicitly queued prompt.
6944+ i.compactContinuation = compactContinuationState{reason: continuationReason}
68436945 continueAutomatically = true
68446946 case len(i.queued) > 0:
68456947 next, i.queued = i.queued[0], i.queued[1:]
68466948 hasNext = true
6847- case autoContinue:
6848- // A successful threshold compaction must hand control back to
6849- // unfinished work. Completed text answers still settle normally
6850- // unless the caller knows the text was truncated and forces this
6851- // continuation.
6949+ i.resetCompactContinuationLocked()
6950+ case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
6951+ if continuationReason == compactContinuationStatusRescue {
6952+ attempts := 1
6953+ if i.compactContinuation.reason == compactContinuationStatusRescue {
6954+ attempts = i.compactContinuation.rescueAttempts + 1
6955+ }
6956+ if attempts > maxStatusRescueContinuations {
6957+ i.resetCompactContinuationLocked()
6958+ break
6959+ }
6960+ i.compactContinuation = compactContinuationState{
6961+ reason: continuationReason,
6962+ rescueAttempts: attempts,
6963+ }
6964+ } else {
6965+ i.compactContinuation = compactContinuationState{reason: continuationReason}
6966+ }
68526967 continueAutomatically = true
6968+ default:
6969+ i.resetCompactContinuationLocked()
68536970 }
68546971 }
68556972 // Keep the host busy until the hand-off decision is committed under
@@ -7039,6 +7156,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
70397156 // The condense flow re-fires the user's queued prompt for us, so
70407157 // we just hand it off and exit.
70417158 i.mu.Lock()
7159+ if !continueExisting {
7160+ i.resetCompactContinuationLocked()
7161+ }
70427162 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
70437163 if needsPreCompact {
70447164 // Reserve the compaction hand-off before releasing i.mu. A completion
@@ -7059,7 +7179,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
70597179 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
70607180 i.mu.Unlock()
70617181 i.invalidate()
7062- i.runCompact(parent, true, false)
7182+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
70637183 return
70647184 }
70657185 i.mu.Unlock()
@@ -7199,6 +7319,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
71997319 }
72007320 i.mu.Lock()
72017321 awaitingPre := i.awaitingStartupPre
7322+ // A newer explicit prompt may have cleared the handoff while the
7323+ // completed turn was being flushed. Re-read it under the mutex before
7324+ // deciding whether this continuation may spend another rescue attempt.
7325+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
72027326 // Pop the next queued message, if any, and relaunch.
72037327 var next string
72047328 var hasNext bool
@@ -7224,6 +7348,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
72247348 }
72257349 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
72267350 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7351+ continueStatusRescue := false
7352+ if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7353+ followUpMessages := i.agent.Messages()
7354+ reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7355+ if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356+ i.compactContinuation.rescueAttempts++
7357+ continueStatusRescue = true
7358+ } else {
7359+ i.resetCompactContinuationLocked()
7360+ }
7361+ }
7362+ if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363+ i.resetCompactContinuationLocked()
7364+ }
72277365 // The agent run can finish before the paced final text reaches the
72287366 // transcript. A compaction replaces that transcript, so it must never
72297367 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7232,7 +7370,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
72327370 i.resetStreamingStateLocked()
72337371 }
72347372 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7235- i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
7373+ i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
72367374 i.mu.Unlock()
72377375 if alertReason != "" {
72387376 i.scheduleMainAlert(alertReason)
@@ -7251,12 +7389,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
72517389 i.startTurn(parent, next)
72527390 case continueQueued:
72537391 i.startTurnRequest(parent, "", nil, true, false)
7392+ case continueStatusRescue:
7393+ i.startAutoCompactContinuation(parent)
72547394 case offer:
72557395 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
72567396 case recoverContextOverflow:
7257- i.runCompact(parent, true, false)
7397+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
72587398 case shouldAutoCompact:
7259- i.runCompact(parent, true, lastStop == provider.StopLength)
7399+ i.runCompact(parent, compactContinuationRequest{
7400+ origin: compactOriginAfterTurnThreshold,
7401+ force: lastStop == provider.StopLength,
7402+ lastStop: lastStop,
7403+ turnError: lastTurnErr,
7404+ })
72607405 }
72617406 }()
72627407 }
@@ -7372,11 +7517,27 @@ func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
73727517 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
73737518 }
73747519
7375-// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
7376-// unfinished. A complete assistant text answer should not cause an extra
7377-// model turn merely because its context was compacted; tool calls, reasoning-
7378-// only replies, and non-assistant tails still need the model to resume.
7379-func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7520+// classifyCompactionContinuation chooses a private handoff reason from the
7521+// successful turn tail. Structural unfinished-tail handling is independent of
7522+// the status matcher. A status rescue is admitted only for an after-turn
7523+// threshold compaction or an already-active status handoff, and only after a
7524+// normal successful end.
7525+func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
7526+ if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
7527+ return compactContinuationStructuralTail
7528+ }
7529+ if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
7530+ return compactContinuationNone
7531+ }
7532+ if likelyForwardWorkStatus(msgs) {
7533+ return compactContinuationStatusRescue
7534+ }
7535+ return compactContinuationNone
7536+}
7537+
7538+// structuralCompactionContinuation preserves the pre-existing unfinished-tail
7539+// behavior without consulting the status matcher.
7540+func structuralCompactionContinuation(msgs []provider.Message) bool {
73807541 if len(msgs) == 0 {
73817542 return false
73827543 }
@@ -7402,6 +7563,95 @@ func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
74027563 return !hasText
74037564 }
74047565
7566+// likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
7567+// first-person future-work commitments and never treats generic plan prose as
7568+// a host control signal.
7569+func likelyForwardWorkStatus(msgs []provider.Message) bool {
7570+ if len(msgs) == 0 {
7571+ return false
7572+ }
7573+ last := msgs[len(msgs)-1]
7574+ if last.Role != provider.RoleAssistant {
7575+ return false
7576+ }
7577+ var text strings.Builder
7578+ for _, content := range last.Content {
7579+ block, ok := content.(provider.TextBlock)
7580+ if !ok {
7581+ if _, hasTool := content.(provider.ToolCallBlock); hasTool {
7582+ return false
7583+ }
7584+ continue
7585+ }
7586+ if strings.TrimSpace(block.Text) == "" {
7587+ continue
7588+ }
7589+ if text.Len() > 0 {
7590+ text.WriteByte(' ')
7591+ }
7592+ text.WriteString(block.Text)
7593+ }
7594+ if text.Len() == 0 {
7595+ return false
7596+ }
7597+ normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
7598+ normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
7599+ for _, phrase := range []string{
7600+ "next i will",
7601+ "next, i will",
7602+ "i'll now",
7603+ "i will now",
7604+ "i am going to",
7605+ "i need to continue",
7606+ "then i will",
7607+ "then, i will",
7608+ "i will proceed",
7609+ } {
7610+ if containsFutureWorkPhrase(normalized, phrase) {
7611+ return true
7612+ }
7613+ }
7614+ return false
7615+}
7616+
7617+func containsFutureWorkPhrase(text, phrase string) bool {
7618+ for offset := 0; offset < len(text); {
7619+ index := strings.Index(text[offset:], phrase)
7620+ if index < 0 {
7621+ return false
7622+ }
7623+ index += offset
7624+ end := index + len(phrase)
7625+ if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
7626+ return true
7627+ }
7628+ offset = end
7629+ }
7630+ return false
7631+}
7632+
7633+func wordRuneBefore(text string, index int) bool {
7634+ if index == 0 {
7635+ return false
7636+ }
7637+ rune_, _ := utf8.DecodeLastRuneInString(text[:index])
7638+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7639+}
7640+
7641+func wordRuneAfter(text string, index int) bool {
7642+ if index == len(text) {
7643+ return false
7644+ }
7645+ rune_, _ := utf8.DecodeRuneInString(text[index:])
7646+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7647+}
7648+
7649+// shouldAutoContinueAfterCompaction reports only the legacy structural-tail
7650+// predicate. Status rescue admission is reason-oriented and origin-aware.
7651+func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7652+ return structuralCompactionContinuation(msgs)
7653+}
7654+
74057655 // shouldAutoCompactLocked reports whether the last turn pushed context
74067656 // usage past the auto-compact threshold. Must be called with i.mu
74077657 // held; it reads lastCtxInput and the current model's context window.
@@ -7622,6 +7872,7 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
76227872 // prepareReplacementAgentLocked applies session-wide capability policy before
76237873 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
76247874 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7875+ i.resetCompactContinuationLocked()
76257876 if ag == nil {
76267877 i.setWebSearchAvailable(false)
76277878 return
@@ -14,6 +14,8 @@ import (
14 "sync"14 "sync"
15 "sync/atomic"15 "sync/atomic"
16 "time"16 "time"
17+ "unicode"
18+ "unicode/utf8"
17 19
18 "github.com/bnema/zut/packages/agent/extensions"20 "github.com/bnema/zut/packages/agent/extensions"
19 "github.com/bnema/zut/packages/agent/extproto"21 "github.com/bnema/zut/packages/agent/extproto"
@@ -497,6 +499,38 @@ type webSearchSettingsStore interface {
497 SetWebSearchEnabled(enabled bool) error499 SetWebSearchEnabled(enabled bool) error
498 }500 }
499 501
502+type compactContinuationOrigin uint8
503+
504+type compactContinuationRequest struct {
505+ origin compactContinuationOrigin
506+ force bool
507+ lastStop provider.StopReason
508+ turnError error
509+}
510+
511+const (
512+ compactOriginManual compactContinuationOrigin = iota
513+ compactOriginPreTurnThreshold
514+ compactOriginAfterTurnThreshold
515+ compactOriginRecovery
516+)
517+
518+type compactContinuationReason uint8
519+
520+const (
521+ compactContinuationNone compactContinuationReason = iota
522+ compactContinuationStructuralTail
523+ compactContinuationForcedLength
524+ compactContinuationStatusRescue
525+)
526+
527+const maxStatusRescueContinuations = 2
528+
529+type compactContinuationState struct {
530+ reason compactContinuationReason
531+ rescueAttempts int
532+}
533+
500 type Interactive struct {534 type Interactive struct {
501 cfg InteractiveConfig535 cfg InteractiveConfig
502 view *tui.View536 view *tui.View
@@ -625,6 +659,11 @@ type Interactive struct {
625 // condense pass from a regular assistant turn.659 // condense pass from a regular assistant turn.
626 autoCompacting bool660 autoCompacting bool
627 661
662+ // compactContinuation records the private reason and bounded rescue
663+ // budget for the live compaction handoff. It is never persisted or
664+ // inferred from rendered transcript rows.
665+ compactContinuation compactContinuationState
666+
628 // updateInfo is the result of the async update check. Zero value667 // updateInfo is the result of the async update check. Zero value
629 // while the check hasn't completed or nothing is available.668 // while the check hasn't completed or nothing is available.
630 updateInfo UpdateInfo669 updateInfo UpdateInfo
@@ -3044,8 +3083,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3044 i.invalidate()3083 i.invalidate()
3045 return false3084 return false
3046 }3085 }
3047- if i.busy && i.cancelTurn != nil {3086+ i.mu.Lock()
3048- i.cancelTurn()3087+ busyCancel := i.busy && i.cancelTurn != nil
3088+ cancelTurn := i.cancelTurn
3089+ if busyCancel {
3090+ i.resetCompactContinuationLocked()
3091+ }
3092+ i.mu.Unlock()
3093+ if busyCancel {
3094+ cancelTurn()
3049 // If a confirm dialog is pending, refuse it so the agent3095 // If a confirm dialog is pending, refuse it so the agent
3050 // goroutine unblocks and the context cancellation can3096 // goroutine unblocks and the context cancellation can
3051 // actually take effect.3097 // actually take effect.
@@ -3358,9 +3404,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3358 }3404 }
3359 clearSubmittedInput()3405 clearSubmittedInput()
3360 if ag != nil && !compacting {3406 if ag != nil && !compacting {
3407+ i.mu.Lock()
3408+ i.resetCompactContinuationLocked()
3361 ag.QueueMessage(text)3409 ag.QueueMessage(text)
3410+ i.mu.Unlock()
3362 } else {3411 } else {
3363 i.mu.Lock()3412 i.mu.Lock()
3413+ i.resetCompactContinuationLocked()
3364 i.queued = append(i.queued, text)3414 i.queued = append(i.queued, text)
3365 i.mu.Unlock()3415 i.mu.Unlock()
3366 }3416 }
@@ -3864,8 +3914,10 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3864 // Compaction uses the host queue because it has no active agent loop3914 // Compaction uses the host queue because it has no active agent loop
3865 // to drain Agent.QueueMessage entries.3915 // to drain Agent.QueueMessage entries.
3866 if i.agent != nil && !i.compacting {3916 if i.agent != nil && !i.compacting {
3917+ i.resetCompactContinuationLocked()
3867 i.agent.QueueMessage(text)3918 i.agent.QueueMessage(text)
3868 } else {3919 } else {
3920+ i.resetCompactContinuationLocked()
3869 i.queued = append(i.queued, text)3921 i.queued = append(i.queued, text)
3870 }3922 }
3871 i.mu.Unlock()3923 i.mu.Unlock()
@@ -3893,6 +3945,9 @@ func (i *Interactive) CancelTurn() {
3893 cancel := i.cancelTurn3945 cancel := i.cancelTurn
3894 i.mu.Unlock()3946 i.mu.Unlock()
3895 if cancel != nil {3947 if cancel != nil {
3948+ i.mu.Lock()
3949+ i.resetCompactContinuationLocked()
3950+ i.mu.Unlock()
3896 cancel()3951 cancel()
3897 i.confirmDialog.CancelAll("turn cancelled")3952 i.confirmDialog.CancelAll("turn cancelled")
3898 }3953 }
@@ -5657,6 +5712,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5657 i.scrollOffset = 05712 i.scrollOffset = 0
5658 i.extNotes = nil5713 i.extNotes = nil
5659 i.reloadErrors = nil5714 i.reloadErrors = nil
5715+ i.resetCompactContinuationLocked()
5660 i.view.InvalidateRenderCache()5716 i.view.InvalidateRenderCache()
5661 i.mu.Unlock()5717 i.mu.Unlock()
5662 case "/help":5718 case "/help":
@@ -5736,7 +5792,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5736 case "/skills":5792 case "/skills":
5737 i.openSkillsDialog()5793 i.openSkillsDialog()
5738 case "/compact":5794 case "/compact":
5739- i.runCompact(ctx, false, false)5795+ i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
5740 case "/study":5796 case "/study":
5741 // Canned prompt that tells the agent to read every file5797 // Canned prompt that tells the agent to read every file
5742 // in some target so its later turns have the whole thing5798 // in some target so its later turns have the whole thing
@@ -5756,9 +5812,13 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5756 i.mu.Unlock()5812 i.mu.Unlock()
5757 if busy {5813 if busy {
5758 if ag != nil && !compacting {5814 if ag != nil && !compacting {
5815+ i.mu.Lock()
5816+ i.resetCompactContinuationLocked()
5759 ag.QueueMessage(studyPrompt)5817 ag.QueueMessage(studyPrompt)
5818+ i.mu.Unlock()
5760 } else {5819 } else {
5761 i.mu.Lock()5820 i.mu.Lock()
5821+ i.resetCompactContinuationLocked()
5762 i.queued = append(i.queued, studyPrompt)5822 i.queued = append(i.queued, studyPrompt)
5763 i.mu.Unlock()5823 i.mu.Unlock()
5764 }5824 }
@@ -6059,6 +6119,7 @@ func (i *Interactive) doLogout(target string) {
6059 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())6119 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6060 }6120 }
6061 i.agent = nil6121 i.agent = nil
6122+ i.resetCompactContinuationLocked()
6062 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."6123 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6063 } else {6124 } else {
6064 i.statusOK = "logged out of " + strings.Join(providers, ", ")6125 i.statusOK = "logged out of " + strings.Join(providers, ", ")
@@ -6244,6 +6305,9 @@ func (i *Interactive) cancelAndWaitForIdle() {
6244 return6305 return
6245 }6306 }
6246 if cancel != nil {6307 if cancel != nil {
6308+ i.mu.Lock()
6309+ i.resetCompactContinuationLocked()
6310+ i.mu.Unlock()
6247 cancel()6311 cancel()
6248 }6312 }
6249 // ConfirmToolCall waits on its response channel rather than the turn6313 // ConfirmToolCall waits on its response channel rather than the turn
@@ -6308,8 +6372,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6308 // goroutine cannot publish idle and inspect an empty queue between6372 // goroutine cannot publish idle and inspect an empty queue between
6309 // this check and the append.6373 // this check and the append.
6310 if i.compacting {6374 if i.compacting {
6375+ i.resetCompactContinuationLocked()
6311 i.queued = append(i.queued, prompt)6376 i.queued = append(i.queued, prompt)
6312 } else {6377 } else {
6378+ i.resetCompactContinuationLocked()
6313 i.agent.QueueMessage(prompt)6379 i.agent.QueueMessage(prompt)
6314 }6380 }
6315 i.mu.Unlock()6381 i.mu.Unlock()
@@ -6682,7 +6748,13 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
6682 i.pendingPostCompactNote = ""6748 i.pendingPostCompactNote = ""
6683 }6749 }
6684 6750
6685-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.`6751+// resetCompactContinuationLocked clears the private handoff state. The
6752+// caller must hold i.mu.
6753+func (i *Interactive) resetCompactContinuationLocked() {
6754+ i.compactContinuation = compactContinuationState{}
6755+}
6756+
6757+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". Inspect 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. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.`
6686 6758
6687 // startAutoCompactContinuation adds an internal user turn that tells the6759 // startAutoCompactContinuation adds an internal user turn that tells the
6688 // model to resume the task after threshold compaction. Provider requests6760 // model to resume the task after threshold compaction. Provider requests
@@ -6692,9 +6764,7 @@ const autoCompactContinuationPrompt = `Context was compacted while work was in p
6692 func (i *Interactive) startAutoCompactContinuation(parent context.Context) {6764 func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6693 i.mu.Lock()6765 i.mu.Lock()
6694 ag := i.agent6766 ag := i.agent
6695- i.mu.Unlock()
6696 if ag == nil {6767 if ag == nil {
6697- i.mu.Lock()
6698 i.busy = false6768 i.busy = false
6699 pendingIdleWork := i.takePendingIdleWorkLocked()6769 pendingIdleWork := i.takePendingIdleWorkLocked()
6700 i.mu.Unlock()6770 i.mu.Unlock()
@@ -6702,11 +6772,28 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6702 i.invalidate()6772 i.invalidate()
6703 return6773 return
6704 }6774 }
6705- if ag.QueuedMessageCount() == 0 {6775+ // The caller selected the continuation while holding i.mu. Keep that
6706- ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{6776+ // ordering through the hidden append so an explicitly queued user prompt
6707- autoCompactContinueMetaKey: "true",6777+ // cannot be overtaken by an old-task handoff. Forced truncation is the
6708- })6778+ // existing exception: it keeps priority and the queued prompt runs after it.
6779+ if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
6780+ next := i.queued[0]
6781+ i.queued = i.queued[1:]
6782+ i.resetCompactContinuationLocked()
6783+ i.mu.Unlock()
6784+ i.startTurn(parent, next)
6785+ return
6709 }6786 }
6787+ if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788+ i.resetCompactContinuationLocked()
6789+ i.mu.Unlock()
6790+ i.startTurnRequest(parent, "", nil, true, false)
6791+ return
6792+ }
6793+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6794+ autoCompactContinueMetaKey: "true",
6795+ })
6796+ i.mu.Unlock()
6710 i.startTurnRequest(parent, "", nil, true, false)6797 i.startTurnRequest(parent, "", nil, true, false)
6711 }6798 }
6712 6799
@@ -6717,9 +6804,10 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6717 // When auto is true the spinner message is pinned to "condensing6804 // When auto is true the spinner message is pinned to "condensing
6718 // history" and the status bar surfaces "(auto)" next to the context6805 // history" and the status bar surfaces "(auto)" next to the context
6719 // percentage so it's obvious the system triggered this, not the user.6806 // percentage so it's obvious the system triggered this, not the user.
6720-// forceAutoContinue tells the post-compact hand-off to resume even if the6807+// request.force tells the post-compact hand-off to resume even if the
6721 // transcript tail has visible assistant text, for example after StopLength.6808 // transcript tail has visible assistant text, for example after StopLength.
6722-func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {6809+func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
6810+ auto := request.origin != compactOriginManual
6723 if i.agent == nil {6811 if i.agent == nil {
6724 i.mu.Lock()6812 i.mu.Lock()
6725 i.statusErr = "not logged in. type /login first."6813 i.statusErr = "not logged in. type /login first."
@@ -6739,6 +6827,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
6739 i.activity.kind = activityCompactingHistory6827 i.activity.kind = activityCompactingHistory
6740 }6828 }
6741 i.cancelTurn = cancel6829 i.cancelTurn = cancel
6830+ if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831+ i.resetCompactContinuationLocked()
6832+ }
6742 i.statusErr = ""6833 i.statusErr = ""
6743 i.statusOK = ""6834 i.statusOK = ""
6744 // Do NOT set streamOn: the summary text should not be visible6835 // Do NOT set streamOn: the summary text should not be visible
@@ -6753,7 +6844,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
6753 // Sink discards deltas — we don't stream the summary to the UI.6844 // Sink discards deltas — we don't stream the summary to the UI.
6754 sink := func(delta string) {}6845 sink := func(delta string) {}
6755 msgsBefore := i.agent.Messages()6846 msgsBefore := i.agent.Messages()
6756- autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))6847+ i.mu.Lock()
6848+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
6849+ i.mu.Unlock()
6850+ continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
6851+ if request.force {
6852+ continuationReason = compactContinuationForcedLength
6853+ }
6757 // Keep the usual recent tail when possible, but never let it cover6854 // Keep the usual recent tail when possible, but never let it cover
6758 // the whole transcript. A short session can still be at 70–90% of6855 // the whole transcript. A short session can still be at 70–90% of
6759 // a model's window because one prompt or tool result is large; the6856 // a model's window because one prompt or tool result is large; the
@@ -6794,6 +6891,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
6794 }6891 }
6795 i.queued = nil // drop queue on cancel6892 i.queued = nil // drop queue on cancel
6796 i.clearPendingCompactTurnLocked()6893 i.clearPendingCompactTurnLocked()
6894+ i.resetCompactContinuationLocked()
6797 if i.agent != nil {6895 if i.agent != nil {
6798 i.agent.DrainQueuedMessages()6896 i.agent.DrainQueuedMessages()
6799 }6897 }
@@ -6802,6 +6900,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
6802 i.statusOK = ""6900 i.statusOK = ""
6803 i.queued = nil // drop queue on error6901 i.queued = nil // drop queue on error
6804 i.clearPendingCompactTurnLocked()6902 i.clearPendingCompactTurnLocked()
6903+ i.resetCompactContinuationLocked()
6805 if i.agent != nil {6904 if i.agent != nil {
6806 i.agent.DrainQueuedMessages()6905 i.agent.DrainQueuedMessages()
6807 }6906 }
@@ -6839,17 +6938,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon
6839 i.pendingCompactImages = nil6938 i.pendingCompactImages = nil
6840 i.hasPendingCompactPrompt = false6939 i.hasPendingCompactPrompt = false
6841 hasNext = true6940 hasNext = true
6842- case forceAutoContinue && autoContinue:6941+ case continuationReason == compactContinuationForcedLength:
6942+ // Forced truncated-output continuation keeps its existing
6943+ // priority over an explicitly queued prompt.
6944+ i.compactContinuation = compactContinuationState{reason: continuationReason}
6843 continueAutomatically = true6945 continueAutomatically = true
6844 case len(i.queued) > 0:6946 case len(i.queued) > 0:
6845 next, i.queued = i.queued[0], i.queued[1:]6947 next, i.queued = i.queued[0], i.queued[1:]
6846 hasNext = true6948 hasNext = true
6847- case autoContinue:6949+ i.resetCompactContinuationLocked()
6848- // A successful threshold compaction must hand control back to6950+ case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
6849- // unfinished work. Completed text answers still settle normally6951+ if continuationReason == compactContinuationStatusRescue {
6850- // unless the caller knows the text was truncated and forces this6952+ attempts := 1
6851- // continuation.6953+ if i.compactContinuation.reason == compactContinuationStatusRescue {
6954+ attempts = i.compactContinuation.rescueAttempts + 1
6955+ }
6956+ if attempts > maxStatusRescueContinuations {
6957+ i.resetCompactContinuationLocked()
6958+ break
6959+ }
6960+ i.compactContinuation = compactContinuationState{
6961+ reason: continuationReason,
6962+ rescueAttempts: attempts,
6963+ }
6964+ } else {
6965+ i.compactContinuation = compactContinuationState{reason: continuationReason}
6966+ }
6852 continueAutomatically = true6967 continueAutomatically = true
6968+ default:
6969+ i.resetCompactContinuationLocked()
6853 }6970 }
6854 }6971 }
6855 // Keep the host busy until the hand-off decision is committed under6972 // Keep the host busy until the hand-off decision is committed under
@@ -7039,6 +7156,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7039 // The condense flow re-fires the user's queued prompt for us, so7156 // The condense flow re-fires the user's queued prompt for us, so
7040 // we just hand it off and exit.7157 // we just hand it off and exit.
7041 i.mu.Lock()7158 i.mu.Lock()
7159+ if !continueExisting {
7160+ i.resetCompactContinuationLocked()
7161+ }
7042 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()7162 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7043 if needsPreCompact {7163 if needsPreCompact {
7044 // Reserve the compaction hand-off before releasing i.mu. A completion7164 // Reserve the compaction hand-off before releasing i.mu. A completion
@@ -7059,7 +7179,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7059 i.pendingPostCompactNote = "context auto-compacted; sending your last message"7179 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7060 i.mu.Unlock()7180 i.mu.Unlock()
7061 i.invalidate()7181 i.invalidate()
7062- i.runCompact(parent, true, false)7182+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7063 return7183 return
7064 }7184 }
7065 i.mu.Unlock()7185 i.mu.Unlock()
@@ -7199,6 +7319,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7199 }7319 }
7200 i.mu.Lock()7320 i.mu.Lock()
7201 awaitingPre := i.awaitingStartupPre7321 awaitingPre := i.awaitingStartupPre
7322+ // A newer explicit prompt may have cleared the handoff while the
7323+ // completed turn was being flushed. Re-read it under the mutex before
7324+ // deciding whether this continuation may spend another rescue attempt.
7325+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7202 // Pop the next queued message, if any, and relaunch.7326 // Pop the next queued message, if any, and relaunch.
7203 var next string7327 var next string
7204 var hasNext bool7328 var hasNext bool
@@ -7224,6 +7348,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7224 }7348 }
7225 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil7349 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7226 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()7350 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7351+ continueStatusRescue := false
7352+ if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7353+ followUpMessages := i.agent.Messages()
7354+ reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7355+ if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356+ i.compactContinuation.rescueAttempts++
7357+ continueStatusRescue = true
7358+ } else {
7359+ i.resetCompactContinuationLocked()
7360+ }
7361+ }
7362+ if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363+ i.resetCompactContinuationLocked()
7364+ }
7227 // The agent run can finish before the paced final text reaches the7365 // The agent run can finish before the paced final text reaches the
7228 // transcript. A compaction replaces that transcript, so it must never7366 // transcript. A compaction replaces that transcript, so it must never
7229 // race the still-live stream frame; otherwise stale deltas can repaint7367 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7232,7 +7370,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7232 i.resetStreamingStateLocked()7370 i.resetStreamingStateLocked()
7233 }7371 }
7234 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)7372 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7235- i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact7373+ i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7236 i.mu.Unlock()7374 i.mu.Unlock()
7237 if alertReason != "" {7375 if alertReason != "" {
7238 i.scheduleMainAlert(alertReason)7376 i.scheduleMainAlert(alertReason)
@@ -7251,12 +7389,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7251 i.startTurn(parent, next)7389 i.startTurn(parent, next)
7252 case continueQueued:7390 case continueQueued:
7253 i.startTurnRequest(parent, "", nil, true, false)7391 i.startTurnRequest(parent, "", nil, true, false)
7392+ case continueStatusRescue:
7393+ i.startAutoCompactContinuation(parent)
7254 case offer:7394 case offer:
7255 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)7395 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7256 case recoverContextOverflow:7396 case recoverContextOverflow:
7257- i.runCompact(parent, true, false)7397+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
7258 case shouldAutoCompact:7398 case shouldAutoCompact:
7259- i.runCompact(parent, true, lastStop == provider.StopLength)7399+ i.runCompact(parent, compactContinuationRequest{
7400+ origin: compactOriginAfterTurnThreshold,
7401+ force: lastStop == provider.StopLength,
7402+ lastStop: lastStop,
7403+ turnError: lastTurnErr,
7404+ })
7260 }7405 }
7261 }()7406 }()
7262 }7407 }
@@ -7372,11 +7517,27 @@ func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7372 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/1007517 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7373 }7518 }
7374 7519
7375-// shouldAutoContinueAfterCompaction reports whether the transcript tail looks7520+// classifyCompactionContinuation chooses a private handoff reason from the
7376-// unfinished. A complete assistant text answer should not cause an extra7521+// successful turn tail. Structural unfinished-tail handling is independent of
7377-// model turn merely because its context was compacted; tool calls, reasoning-7522+// the status matcher. A status rescue is admitted only for an after-turn
7378-// only replies, and non-assistant tails still need the model to resume.7523+// threshold compaction or an already-active status handoff, and only after a
7379-func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {7524+// normal successful end.
7525+func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
7526+ if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
7527+ return compactContinuationStructuralTail
7528+ }
7529+ if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
7530+ return compactContinuationNone
7531+ }
7532+ if likelyForwardWorkStatus(msgs) {
7533+ return compactContinuationStatusRescue
7534+ }
7535+ return compactContinuationNone
7536+}
7537+
7538+// structuralCompactionContinuation preserves the pre-existing unfinished-tail
7539+// behavior without consulting the status matcher.
7540+func structuralCompactionContinuation(msgs []provider.Message) bool {
7380 if len(msgs) == 0 {7541 if len(msgs) == 0 {
7381 return false7542 return false
7382 }7543 }
@@ -7402,6 +7563,95 @@ func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7402 return !hasText7563 return !hasText
7403 }7564 }
7404 7565
7566+// likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
7567+// first-person future-work commitments and never treats generic plan prose as
7568+// a host control signal.
7569+func likelyForwardWorkStatus(msgs []provider.Message) bool {
7570+ if len(msgs) == 0 {
7571+ return false
7572+ }
7573+ last := msgs[len(msgs)-1]
7574+ if last.Role != provider.RoleAssistant {
7575+ return false
7576+ }
7577+ var text strings.Builder
7578+ for _, content := range last.Content {
7579+ block, ok := content.(provider.TextBlock)
7580+ if !ok {
7581+ if _, hasTool := content.(provider.ToolCallBlock); hasTool {
7582+ return false
7583+ }
7584+ continue
7585+ }
7586+ if strings.TrimSpace(block.Text) == "" {
7587+ continue
7588+ }
7589+ if text.Len() > 0 {
7590+ text.WriteByte(' ')
7591+ }
7592+ text.WriteString(block.Text)
7593+ }
7594+ if text.Len() == 0 {
7595+ return false
7596+ }
7597+ normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
7598+ normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
7599+ for _, phrase := range []string{
7600+ "next i will",
7601+ "next, i will",
7602+ "i'll now",
7603+ "i will now",
7604+ "i am going to",
7605+ "i need to continue",
7606+ "then i will",
7607+ "then, i will",
7608+ "i will proceed",
7609+ } {
7610+ if containsFutureWorkPhrase(normalized, phrase) {
7611+ return true
7612+ }
7613+ }
7614+ return false
7615+}
7616+
7617+func containsFutureWorkPhrase(text, phrase string) bool {
7618+ for offset := 0; offset < len(text); {
7619+ index := strings.Index(text[offset:], phrase)
7620+ if index < 0 {
7621+ return false
7622+ }
7623+ index += offset
7624+ end := index + len(phrase)
7625+ if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
7626+ return true
7627+ }
7628+ offset = end
7629+ }
7630+ return false
7631+}
7632+
7633+func wordRuneBefore(text string, index int) bool {
7634+ if index == 0 {
7635+ return false
7636+ }
7637+ rune_, _ := utf8.DecodeLastRuneInString(text[:index])
7638+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7639+}
7640+
7641+func wordRuneAfter(text string, index int) bool {
7642+ if index == len(text) {
7643+ return false
7644+ }
7645+ rune_, _ := utf8.DecodeRuneInString(text[index:])
7646+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7647+}
7648+
7649+// shouldAutoContinueAfterCompaction reports only the legacy structural-tail
7650+// predicate. Status rescue admission is reason-oriented and origin-aware.
7651+func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7652+ return structuralCompactionContinuation(msgs)
7653+}
7654+
7405 // shouldAutoCompactLocked reports whether the last turn pushed context7655 // shouldAutoCompactLocked reports whether the last turn pushed context
7406 // usage past the auto-compact threshold. Must be called with i.mu7656 // usage past the auto-compact threshold. Must be called with i.mu
7407 // held; it reads lastCtxInput and the current model's context window.7657 // held; it reads lastCtxInput and the current model's context window.
@@ -7622,6 +7872,7 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
7622 // prepareReplacementAgentLocked applies session-wide capability policy before7872 // prepareReplacementAgentLocked applies session-wide capability policy before
7623 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.7873 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
7624 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {7874 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7875+ i.resetCompactContinuationLocked()
7625 if ag == nil {7876 if ag == nil {
7626 i.setWebSearchAvailable(false)7877 i.setWebSearchAvailable(false)
7627 return7878 return
M packages/agent/ponytail.md +5 -0
@@ -11,4 +11,9 @@ When coding, debugging, reviewing, or changing software:
1111 - Avoid speculative features, boilerplate, and unrelated refactors.
1212 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
1313
14+For an active multi-step implementation task, a progress report, plan update,
15+or statement of future work is not a completed response. Continue with the
16+next concrete action now. End only after required work and validation are
17+complete, or when blocked on a specific decision that requires the user.
18+
1419 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
@@ -11,4 +11,9 @@ When coding, debugging, reviewing, or changing software:
11 - Avoid speculative features, boilerplate, and unrelated refactors.11 - Avoid speculative features, boilerplate, and unrelated refactors.
12 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.12 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
13 13
14+For an active multi-step implementation task, a progress report, plan update,
15+or statement of future work is not a completed response. Continue with the
16+next concrete action now. End only after required work and validation are
17+complete, or when blocked on a specific decision that requires the user.
18+
14 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.19 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
M packages/agent/ponytail_test.go +1 -1
@@ -34,7 +34,7 @@ func TestPonytailSystemAddendumIsCompact(t *testing.T) {
3434 if len(addendum) > 2000 {
3535 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
3636 }
37- for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {
37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation", "active multi-step implementation task", "required work and validation", "specific decision that requires the user"} {
3838 if !strings.Contains(addendum, want) {
3939 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
4040 }
@@ -34,7 +34,7 @@ func TestPonytailSystemAddendumIsCompact(t *testing.T) {
34 if len(addendum) > 2000 {34 if len(addendum) > 2000 {
35 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))35 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
36 }36 }
37- for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation", "active multi-step implementation task", "required work and validation", "specific decision that requires the user"} {
38 if !strings.Contains(addendum, want) {38 if !strings.Contains(addendum, want) {
39 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)39 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
40 }40 }
M packages/agent/systemprompt_test.go +5 -0
@@ -16,4 +16,9 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
1616 if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
1717 t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
1818 }
19+ for _, want := range []string{"most recent unresolved user request", "newer user request", "without waiting for the user to type \"continue\""} {
20+ if !strings.Contains(prompt, want) {
21+ t.Fatalf("compaction handoff missing %q:\n%s", want, prompt)
22+ }
23+ }
1924 }
@@ -16,4 +16,9 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
16 if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {16 if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
17 t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)17 t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
18 }18 }
19+ for _, want := range []string{"most recent unresolved user request", "newer user request", "without waiting for the user to type \"continue\""} {
20+ if !strings.Contains(prompt, want) {
21+ t.Fatalf("compaction handoff missing %q:\n%s", want, prompt)
22+ }
23+ }
19 }24 }

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