feat(interactive): rescue forward-work status after compaction
- commit
- e995d3ab7de64043380e6f3175fbb08615cdd05e
- author
- brice · · 2026-08-07 05:33 UTC
- parent
- 5bf8ac4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 407 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 429 | ### `/jail` | |
| 430 | 430 | ||
| @@ -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) { | |||
| 646 | 646 | t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token") | |
| 647 | 647 | t.Setenv("AWS_REGION", "us-east-1") | |
| 648 | 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 | 650 | t.Setenv(k, "") | |
| 651 | 651 | } | |
| 652 | 652 | ||
| @@ -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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/zut/packages/provider" | |
| @@ -33,54 +34,205 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) { | |||
| 33 | 34 | ||
| 34 | 35 | func TestShouldAutoContinueAfterCompaction(t *testing.T) { | |
| 35 | 36 | 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 | |
| 39 | 44 | }{ | |
| 40 | 45 | { | |
| 41 | - | name: "completed text answer", | |
| 46 | + | name: "completed text answer", | |
| 47 | + | origin: compactOriginAfterTurnThreshold, | |
| 48 | + | stop: provider.StopEnd, | |
| 42 | 49 | msgs: []provider.Message{{ | |
| 43 | 50 | Role: provider.RoleAssistant, | |
| 44 | 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 | 59 | msgs: []provider.Message{{ | |
| 51 | 60 | Role: provider.RoleAssistant, | |
| 52 | 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 | 69 | msgs: []provider.Message{{ | |
| 59 | 70 | Role: provider.RoleAssistant, | |
| 60 | 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 | 79 | msgs: []provider.Message{{ | |
| 67 | 80 | Role: provider.RoleUser, | |
| 68 | 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 | 205 | for _, tt := range tests { | |
| 76 | 206 | 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) | |
| 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 | 236 | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |
| 85 | 237 | tests := []struct { | |
| 86 | 238 | name string | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package modes | 1 | 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 string | 37 | + | name string |
| 37 | - | msgs []provider.Message | 38 | + | origin compactContinuationOrigin |
| 38 | - | want bool | 39 | + | 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 string | 238 | name string | ||
M packages/agent/modes/compact_queue_test.go +318 -2
| @@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||
| 153 | 153 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 154 | 154 | interactive.runCtx = context.Background() | |
| 155 | 155 | ||
| 156 | - | interactive.runCompact(context.Background(), false, false) | |
| 156 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual}) | |
| 157 | 157 | select { | |
| 158 | 158 | case <-client.compactionStarted: | |
| 159 | 159 | case <-time.After(2 * time.Second): | |
| @@ -180,6 +180,9 @@ type thresholdAutoCompactClient struct { | |||
| 180 | 180 | followUpRequest chan provider.Request | |
| 181 | 181 | firstStop provider.StopReason | |
| 182 | 182 | firstContent []provider.Content | |
| 183 | + | followUpContents []string | |
| 184 | + | blockFollowUpCall int | |
| 185 | + | releaseFollowUp <-chan struct{} | |
| 183 | 186 | ||
| 184 | 187 | mu sync.Mutex | |
| 185 | 188 | calls int | |
| @@ -227,13 +230,26 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||
| 227 | 230 | default: | |
| 228 | 231 | c.mu.Lock() | |
| 229 | 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 | 238 | c.mu.Unlock() | |
| 231 | 239 | 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 | 248 | out <- provider.EventDone{ | |
| 233 | 249 | Stop: provider.StopEnd, | |
| 234 | 250 | Message: provider.Message{ | |
| 235 | 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 | 594 | func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) { | |
| 279 | 595 | client := &thresholdAutoCompactClient{ | |
| 280 | 596 | 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.Request | 180 | followUpRequest chan provider.Request | ||
| 181 | firstStop provider.StopReason | 181 | firstStop provider.StopReason | ||
| 182 | firstContent []provider.Content | 182 | firstContent []provider.Content | ||
| 183 | + | followUpContents []string | |||
| 184 | + | blockFollowUpCall int | |||
| 185 | + | releaseFollowUp <-chan struct{} | |||
| 183 | 186 | ||||
| 184 | mu sync.Mutex | 187 | mu sync.Mutex | ||
| 185 | calls int | 188 | 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 <- req | 239 | 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 ( | |||
| 14 | 14 | "sync" | |
| 15 | 15 | "sync/atomic" | |
| 16 | 16 | "time" | |
| 17 | + | "unicode" | |
| 18 | + | "unicode/utf8" | |
| 17 | 19 | ||
| 18 | 20 | "github.com/bnema/zut/packages/agent/extensions" | |
| 19 | 21 | "github.com/bnema/zut/packages/agent/extproto" | |
| @@ -497,6 +499,38 @@ type webSearchSettingsStore interface { | |||
| 497 | 499 | 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 | 534 | type Interactive struct { | |
| 501 | 535 | cfg InteractiveConfig | |
| 502 | 536 | view *tui.View | |
| @@ -625,6 +659,11 @@ type Interactive struct { | |||
| 625 | 659 | // condense pass from a regular assistant turn. | |
| 626 | 660 | 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 | 667 | // updateInfo is the result of the async update check. Zero value | |
| 629 | 668 | // while the check hasn't completed or nothing is available. | |
| 630 | 669 | updateInfo UpdateInfo | |
| @@ -3044,8 +3083,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3044 | 3083 | i.invalidate() | |
| 3045 | 3084 | return false | |
| 3046 | 3085 | } | |
| 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() | |
| 3049 | 3095 | // If a confirm dialog is pending, refuse it so the agent | |
| 3050 | 3096 | // goroutine unblocks and the context cancellation can | |
| 3051 | 3097 | // actually take effect. | |
| @@ -3358,9 +3404,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3358 | 3404 | } | |
| 3359 | 3405 | clearSubmittedInput() | |
| 3360 | 3406 | if ag != nil && !compacting { | |
| 3407 | + | i.mu.Lock() | |
| 3408 | + | i.resetCompactContinuationLocked() | |
| 3361 | 3409 | ag.QueueMessage(text) | |
| 3410 | + | i.mu.Unlock() | |
| 3362 | 3411 | } else { | |
| 3363 | 3412 | i.mu.Lock() | |
| 3413 | + | i.resetCompactContinuationLocked() | |
| 3364 | 3414 | i.queued = append(i.queued, text) | |
| 3365 | 3415 | i.mu.Unlock() | |
| 3366 | 3416 | } | |
| @@ -3864,8 +3914,10 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||
| 3864 | 3914 | // Compaction uses the host queue because it has no active agent loop | |
| 3865 | 3915 | // to drain Agent.QueueMessage entries. | |
| 3866 | 3916 | if i.agent != nil && !i.compacting { | |
| 3917 | + | i.resetCompactContinuationLocked() | |
| 3867 | 3918 | i.agent.QueueMessage(text) | |
| 3868 | 3919 | } else { | |
| 3920 | + | i.resetCompactContinuationLocked() | |
| 3869 | 3921 | i.queued = append(i.queued, text) | |
| 3870 | 3922 | } | |
| 3871 | 3923 | i.mu.Unlock() | |
| @@ -3893,6 +3945,9 @@ func (i *Interactive) CancelTurn() { | |||
| 3893 | 3945 | cancel := i.cancelTurn | |
| 3894 | 3946 | i.mu.Unlock() | |
| 3895 | 3947 | if cancel != nil { | |
| 3948 | + | i.mu.Lock() | |
| 3949 | + | i.resetCompactContinuationLocked() | |
| 3950 | + | i.mu.Unlock() | |
| 3896 | 3951 | cancel() | |
| 3897 | 3952 | i.confirmDialog.CancelAll("turn cancelled") | |
| 3898 | 3953 | } | |
| @@ -5657,6 +5712,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5657 | 5712 | i.scrollOffset = 0 | |
| 5658 | 5713 | i.extNotes = nil | |
| 5659 | 5714 | i.reloadErrors = nil | |
| 5715 | + | i.resetCompactContinuationLocked() | |
| 5660 | 5716 | i.view.InvalidateRenderCache() | |
| 5661 | 5717 | i.mu.Unlock() | |
| 5662 | 5718 | case "/help": | |
| @@ -5736,7 +5792,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5736 | 5792 | case "/skills": | |
| 5737 | 5793 | i.openSkillsDialog() | |
| 5738 | 5794 | case "/compact": | |
| 5739 | - | i.runCompact(ctx, false, false) | |
| 5795 | + | i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual}) | |
| 5740 | 5796 | case "/study": | |
| 5741 | 5797 | // Canned prompt that tells the agent to read every file | |
| 5742 | 5798 | // 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 | 5812 | i.mu.Unlock() | |
| 5757 | 5813 | if busy { | |
| 5758 | 5814 | if ag != nil && !compacting { | |
| 5815 | + | i.mu.Lock() | |
| 5816 | + | i.resetCompactContinuationLocked() | |
| 5759 | 5817 | ag.QueueMessage(studyPrompt) | |
| 5818 | + | i.mu.Unlock() | |
| 5760 | 5819 | } else { | |
| 5761 | 5820 | i.mu.Lock() | |
| 5821 | + | i.resetCompactContinuationLocked() | |
| 5762 | 5822 | i.queued = append(i.queued, studyPrompt) | |
| 5763 | 5823 | i.mu.Unlock() | |
| 5764 | 5824 | } | |
| @@ -6059,6 +6119,7 @@ func (i *Interactive) doLogout(target string) { | |||
| 6059 | 6119 | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 6060 | 6120 | } | |
| 6061 | 6121 | i.agent = nil | |
| 6122 | + | i.resetCompactContinuationLocked() | |
| 6062 | 6123 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 6063 | 6124 | } else { | |
| 6064 | 6125 | i.statusOK = "logged out of " + strings.Join(providers, ", ") | |
| @@ -6244,6 +6305,9 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||
| 6244 | 6305 | return | |
| 6245 | 6306 | } | |
| 6246 | 6307 | if cancel != nil { | |
| 6308 | + | i.mu.Lock() | |
| 6309 | + | i.resetCompactContinuationLocked() | |
| 6310 | + | i.mu.Unlock() | |
| 6247 | 6311 | cancel() | |
| 6248 | 6312 | } | |
| 6249 | 6313 | // ConfirmToolCall waits on its response channel rather than the turn | |
| @@ -6308,8 +6372,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 6308 | 6372 | // goroutine cannot publish idle and inspect an empty queue between | |
| 6309 | 6373 | // this check and the append. | |
| 6310 | 6374 | if i.compacting { | |
| 6375 | + | i.resetCompactContinuationLocked() | |
| 6311 | 6376 | i.queued = append(i.queued, prompt) | |
| 6312 | 6377 | } else { | |
| 6378 | + | i.resetCompactContinuationLocked() | |
| 6313 | 6379 | i.agent.QueueMessage(prompt) | |
| 6314 | 6380 | } | |
| 6315 | 6381 | i.mu.Unlock() | |
| @@ -6682,7 +6748,13 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||
| 6682 | 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 | 6759 | // startAutoCompactContinuation adds an internal user turn that tells the | |
| 6688 | 6760 | // 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 | 6764 | func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |
| 6693 | 6765 | i.mu.Lock() | |
| 6694 | 6766 | ag := i.agent | |
| 6695 | - | i.mu.Unlock() | |
| 6696 | 6767 | if ag == nil { | |
| 6697 | - | i.mu.Lock() | |
| 6698 | 6768 | i.busy = false | |
| 6699 | 6769 | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 6700 | 6770 | i.mu.Unlock() | |
| @@ -6702,11 +6772,28 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 6702 | 6772 | i.invalidate() | |
| 6703 | 6773 | return | |
| 6704 | 6774 | } | |
| 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 | |
| 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 | 6797 | i.startTurnRequest(parent, "", nil, true, false) | |
| 6711 | 6798 | } | |
| 6712 | 6799 | ||
| @@ -6717,9 +6804,10 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 6717 | 6804 | // When auto is true the spinner message is pinned to "condensing | |
| 6718 | 6805 | // history" and the status bar surfaces "(auto)" next to the context | |
| 6719 | 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 the | |
| 6807 | + | // request.force tells the post-compact hand-off to resume even if the | |
| 6721 | 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 | 6811 | if i.agent == nil { | |
| 6724 | 6812 | i.mu.Lock() | |
| 6725 | 6813 | i.statusErr = "not logged in. type /login first." | |
| @@ -6739,6 +6827,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon | |||
| 6739 | 6827 | i.activity.kind = activityCompactingHistory | |
| 6740 | 6828 | } | |
| 6741 | 6829 | i.cancelTurn = cancel | |
| 6830 | + | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | |
| 6831 | + | i.resetCompactContinuationLocked() | |
| 6832 | + | } | |
| 6742 | 6833 | i.statusErr = "" | |
| 6743 | 6834 | i.statusOK = "" | |
| 6744 | 6835 | // 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 | 6844 | // Sink discards deltas — we don't stream the summary to the UI. | |
| 6754 | 6845 | sink := func(delta string) {} | |
| 6755 | 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 | 6854 | // Keep the usual recent tail when possible, but never let it cover | |
| 6758 | 6855 | // the whole transcript. A short session can still be at 70–90% of | |
| 6759 | 6856 | // 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 | 6892 | i.queued = nil // drop queue on cancel | |
| 6796 | 6893 | i.clearPendingCompactTurnLocked() | |
| 6894 | + | i.resetCompactContinuationLocked() | |
| 6797 | 6895 | if i.agent != nil { | |
| 6798 | 6896 | i.agent.DrainQueuedMessages() | |
| 6799 | 6897 | } | |
| @@ -6802,6 +6900,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon | |||
| 6802 | 6900 | i.statusOK = "" | |
| 6803 | 6901 | i.queued = nil // drop queue on error | |
| 6804 | 6902 | i.clearPendingCompactTurnLocked() | |
| 6903 | + | i.resetCompactContinuationLocked() | |
| 6805 | 6904 | if i.agent != nil { | |
| 6806 | 6905 | i.agent.DrainQueuedMessages() | |
| 6807 | 6906 | } | |
| @@ -6839,17 +6938,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoCon | |||
| 6839 | 6938 | i.pendingCompactImages = nil | |
| 6840 | 6939 | i.hasPendingCompactPrompt = false | |
| 6841 | 6940 | 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 | 6945 | continueAutomatically = true | |
| 6844 | 6946 | case len(i.queued) > 0: | |
| 6845 | 6947 | next, i.queued = i.queued[0], i.queued[1:] | |
| 6846 | 6948 | 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 | + | } | |
| 6852 | 6967 | continueAutomatically = true | |
| 6968 | + | default: | |
| 6969 | + | i.resetCompactContinuationLocked() | |
| 6853 | 6970 | } | |
| 6854 | 6971 | } | |
| 6855 | 6972 | // 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 | 7156 | // The condense flow re-fires the user's queued prompt for us, so | |
| 7040 | 7157 | // we just hand it off and exit. | |
| 7041 | 7158 | i.mu.Lock() | |
| 7159 | + | if !continueExisting { | |
| 7160 | + | i.resetCompactContinuationLocked() | |
| 7161 | + | } | |
| 7042 | 7162 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 7043 | 7163 | if needsPreCompact { | |
| 7044 | 7164 | // 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 | 7179 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 7060 | 7180 | i.mu.Unlock() | |
| 7061 | 7181 | i.invalidate() | |
| 7062 | - | i.runCompact(parent, true, false) | |
| 7182 | + | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | |
| 7063 | 7183 | return | |
| 7064 | 7184 | } | |
| 7065 | 7185 | i.mu.Unlock() | |
| @@ -7199,6 +7319,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7199 | 7319 | } | |
| 7200 | 7320 | i.mu.Lock() | |
| 7201 | 7321 | 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 | 7326 | // Pop the next queued message, if any, and relaunch. | |
| 7203 | 7327 | var next string | |
| 7204 | 7328 | var hasNext bool | |
| @@ -7224,6 +7348,20 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7224 | 7348 | } | |
| 7225 | 7349 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 7226 | 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 | 7365 | // The agent run can finish before the paced final text reaches the | |
| 7228 | 7366 | // transcript. A compaction replaces that transcript, so it must never | |
| 7229 | 7367 | // 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 | 7370 | i.resetStreamingStateLocked() | |
| 7233 | 7371 | } | |
| 7234 | 7372 | 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 | |
| 7236 | 7374 | i.mu.Unlock() | |
| 7237 | 7375 | if alertReason != "" { | |
| 7238 | 7376 | i.scheduleMainAlert(alertReason) | |
| @@ -7251,12 +7389,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7251 | 7389 | i.startTurn(parent, next) | |
| 7252 | 7390 | case continueQueued: | |
| 7253 | 7391 | i.startTurnRequest(parent, "", nil, true, false) | |
| 7392 | + | case continueStatusRescue: | |
| 7393 | + | i.startAutoCompactContinuation(parent) | |
| 7254 | 7394 | case offer: | |
| 7255 | 7395 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 7256 | 7396 | case recoverContextOverflow: | |
| 7257 | - | i.runCompact(parent, true, false) | |
| 7397 | + | i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery}) | |
| 7258 | 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 | 7517 | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | |
| 7373 | 7518 | } | |
| 7374 | 7519 | ||
| 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 { | |
| 7380 | 7541 | if len(msgs) == 0 { | |
| 7381 | 7542 | return false | |
| 7382 | 7543 | } | |
| @@ -7402,6 +7563,95 @@ func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool { | |||
| 7402 | 7563 | 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 | 7655 | // shouldAutoCompactLocked reports whether the last turn pushed context | |
| 7406 | 7656 | // usage past the auto-compact threshold. Must be called with i.mu | |
| 7407 | 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 | 7872 | // prepareReplacementAgentLocked applies session-wide capability policy before | |
| 7623 | 7873 | // ag becomes visible to prompt submission. The caller holds agentMu and i.mu. | |
| 7624 | 7874 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 7875 | + | i.resetCompactContinuationLocked() | |
| 7625 | 7876 | if ag == nil { | |
| 7626 | 7877 | i.setWebSearchAvailable(false) | |
| 7627 | 7878 | 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) error | 499 | 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 InteractiveConfig | 535 | cfg InteractiveConfig | ||
| 502 | view *tui.View | 536 | 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 bool | 660 | 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 value | 667 | // 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 UpdateInfo | 669 | 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 false | 3084 | 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 agent | 3095 | // If a confirm dialog is pending, refuse it so the agent | ||
| 3050 | // goroutine unblocks and the context cancellation can | 3096 | // 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 loop | 3914 | // 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.cancelTurn | 3945 | 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 = 0 | 5712 | i.scrollOffset = 0 | ||
| 5658 | i.extNotes = nil | 5713 | i.extNotes = nil | ||
| 5659 | i.reloadErrors = nil | 5714 | 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 file | 5797 | // Canned prompt that tells the agent to read every file | ||
| 5742 | // in some target so its later turns have the whole thing | 5798 | // 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 = nil | 6121 | 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 | return | 6305 | 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 turn | 6313 | // 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 between | 6372 | // 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 the | 6759 | // startAutoCompactContinuation adds an internal user turn that tells the | ||
| 6688 | // model to resume the task after threshold compaction. Provider requests | 6760 | // 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.agent | 6766 | ag := i.agent | ||
| 6695 | - | i.mu.Unlock() | |||
| 6696 | if ag == nil { | 6767 | if ag == nil { | ||
| 6697 | - | i.mu.Lock() | |||
| 6698 | i.busy = false | 6768 | 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 | return | 6773 | 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 "condensing | 6804 | // When auto is true the spinner message is pinned to "condensing | ||
| 6718 | // history" and the status bar surfaces "(auto)" next to the context | 6805 | // 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 the | 6807 | + | // 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 = activityCompactingHistory | 6827 | i.activity.kind = activityCompactingHistory | ||
| 6740 | } | 6828 | } | ||
| 6741 | i.cancelTurn = cancel | 6829 | 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 visible | 6835 | // 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 cover | 6854 | // 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% of | 6855 | // 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; the | 6856 | // 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 cancel | 6892 | 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 error | 6901 | 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 = nil | 6938 | i.pendingCompactImages = nil | ||
| 6840 | i.hasPendingCompactPrompt = false | 6939 | i.hasPendingCompactPrompt = false | ||
| 6841 | hasNext = true | 6940 | 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 = true | 6945 | 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 = true | 6948 | hasNext = true | ||
| 6847 | - | case autoContinue: | 6949 | + | i.resetCompactContinuationLocked() |
| 6848 | - | // A successful threshold compaction must hand control back to | 6950 | + | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: |
| 6849 | - | // unfinished work. Completed text answers still settle normally | 6951 | + | if continuationReason == compactContinuationStatusRescue { |
| 6850 | - | // unless the caller knows the text was truncated and forces this | 6952 | + | 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 = true | 6967 | continueAutomatically = true | ||
| 6968 | + | default: | |||
| 6969 | + | i.resetCompactContinuationLocked() | |||
| 6853 | } | 6970 | } | ||
| 6854 | } | 6971 | } | ||
| 6855 | // Keep the host busy until the hand-off decision is committed under | 6972 | // 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, so | 7156 | // 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 completion | 7164 | // 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 | return | 7183 | 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.awaitingStartupPre | 7321 | 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 string | 7327 | var next string | ||
| 7204 | var hasNext bool | 7328 | 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() == nil | 7349 | 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 the | 7365 | // The agent run can finish before the paced final text reaches the | ||
| 7228 | // transcript. A compaction replaces that transcript, so it must never | 7366 | // transcript. A compaction replaces that transcript, so it must never | ||
| 7229 | // race the still-live stream frame; otherwise stale deltas can repaint | 7367 | // 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 || shouldAutoCompact | 7373 | + | 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)/100 | 7517 | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | ||
| 7373 | } | 7518 | } | ||
| 7374 | 7519 | ||||
| 7375 | - | // shouldAutoContinueAfterCompaction reports whether the transcript tail looks | 7520 | + | // classifyCompactionContinuation chooses a private handoff reason from the |
| 7376 | - | // unfinished. A complete assistant text answer should not cause an extra | 7521 | + | // 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 false | 7542 | return false | ||
| 7382 | } | 7543 | } | ||
| @@ -7402,6 +7563,95 @@ func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool { | |||||
| 7402 | return !hasText | 7563 | 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 context | 7655 | // shouldAutoCompactLocked reports whether the last turn pushed context | ||
| 7406 | // usage past the auto-compact threshold. Must be called with i.mu | 7656 | // 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 before | 7872 | // 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 | return | 7878 | return | ||
M packages/agent/ponytail.md +5 -0
| @@ -11,4 +11,9 @@ When coding, debugging, reviewing, or changing software: | |||
| 11 | 11 | - Avoid speculative features, boilerplate, and unrelated refactors. | |
| 12 | 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 | 19 | 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) { | |||
| 34 | 34 | if len(addendum) > 2000 { | |
| 35 | 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 | 38 | if !strings.Contains(addendum, want) { | |
| 39 | 39 | t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum) | |
| 40 | 40 | } | |
| @@ -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) { | |||
| 16 | 16 | if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 { | |
| 17 | 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 | } | |
| @@ -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 | } | ||