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