fix(interactive): harden compact handoff recovery
- commit
- 8293ec4ed3f94168678208478cc4160b28699ec2
- author
- brice · · 2026-08-07 08:05 UTC
- parent
- f73cf18
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +201 -20
M README.md +1 -1
| @@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||
| 424 | 424 | ||
| 425 | 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 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. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. 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. | |
| 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. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. 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 | ||
| @@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||||
| 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. Manual `/compact` only summarizes; it does not silently start work afterward. 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 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. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. 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. | 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. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. 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/modes/auto_compact_test.go +34 -6
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "encoding/json" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "testing" | |
| @@ -245,29 +244,58 @@ func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) { | |||
| 245 | 244 | } | |
| 246 | 245 | } | |
| 247 | 246 | ||
| 248 | - | func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) { | |
| 247 | + | func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) { | |
| 249 | 248 | persisted := make(chan string, 1) | |
| 249 | + | cancelled := make(chan struct{}) | |
| 250 | + | persistenceStarted := make(chan struct{}) | |
| 251 | + | releasePersistence := make(chan struct{}) | |
| 252 | + | released := false | |
| 253 | + | defer func() { | |
| 254 | + | if !released { | |
| 255 | + | close(releasePersistence) | |
| 256 | + | } | |
| 257 | + | }() | |
| 258 | + | ||
| 250 | 259 | interactive := NewInteractive(InteractiveConfig{ | |
| 251 | 260 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 252 | 261 | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 262 | + | close(persistenceStarted) | |
| 263 | + | <-releasePersistence | |
| 253 | 264 | persisted <- string(state) | |
| 254 | 265 | return nil | |
| 255 | 266 | }, | |
| 256 | 267 | }) | |
| 257 | - | _, cancel := context.WithCancel(context.Background()) | |
| 258 | 268 | interactive.mu.Lock() | |
| 259 | 269 | interactive.busy = true | |
| 260 | - | interactive.cancelTurn = cancel | |
| 270 | + | interactive.cancelTurn = func() { close(cancelled) } | |
| 261 | 271 | interactive.mu.Unlock() | |
| 262 | 272 | ||
| 263 | - | interactive.CancelTurn() | |
| 273 | + | done := make(chan struct{}) | |
| 274 | + | go func() { | |
| 275 | + | interactive.CancelTurn() | |
| 276 | + | close(done) | |
| 277 | + | }() | |
| 278 | + | ||
| 279 | + | select { | |
| 280 | + | case <-cancelled: | |
| 281 | + | case <-time.After(2 * time.Second): | |
| 282 | + | t.Fatal("cancellation waited for compact handoff persistence") | |
| 283 | + | } | |
| 284 | + | select { | |
| 285 | + | case <-persistenceStarted: | |
| 286 | + | case <-time.After(2 * time.Second): | |
| 287 | + | t.Fatal("cancellation did not persist the cleared compact handoff") | |
| 288 | + | } | |
| 289 | + | close(releasePersistence) | |
| 290 | + | released = true | |
| 291 | + | <-done | |
| 264 | 292 | select { | |
| 265 | 293 | case state := <-persisted: | |
| 266 | 294 | if state != "" { | |
| 267 | 295 | t.Fatalf("cleared compact handoff = %q, want empty", state) | |
| 268 | 296 | } | |
| 269 | 297 | case <-time.After(2 * time.Second): | |
| 270 | - | t.Fatal("cancellation did not clear the persisted compact handoff") | |
| 298 | + | t.Fatal("cancellation did not finish compact handoff persistence") | |
| 271 | 299 | } | |
| 272 | 300 | } | |
| 273 | 301 | ||
| @@ -1,7 +1,6 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "encoding/json" | 4 | "encoding/json" | ||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "testing" | 6 | "testing" | ||
| @@ -245,29 +244,58 @@ func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) { | |||||
| 245 | } | 244 | } | ||
| 246 | } | 245 | } | ||
| 247 | 246 | ||||
| 248 | - | func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) { | 247 | + | func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) { |
| 249 | persisted := make(chan string, 1) | 248 | persisted := make(chan string, 1) | ||
| 249 | + | cancelled := make(chan struct{}) | |||
| 250 | + | persistenceStarted := make(chan struct{}) | |||
| 251 | + | releasePersistence := make(chan struct{}) | |||
| 252 | + | released := false | |||
| 253 | + | defer func() { | |||
| 254 | + | if !released { | |||
| 255 | + | close(releasePersistence) | |||
| 256 | + | } | |||
| 257 | + | }() | |||
| 258 | + | ||||
| 250 | interactive := NewInteractive(InteractiveConfig{ | 259 | interactive := NewInteractive(InteractiveConfig{ | ||
| 251 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | 260 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | ||
| 252 | PersistCompactHandoff: func(state json.RawMessage) error { | 261 | PersistCompactHandoff: func(state json.RawMessage) error { | ||
| 262 | + | close(persistenceStarted) | |||
| 263 | + | <-releasePersistence | |||
| 253 | persisted <- string(state) | 264 | persisted <- string(state) | ||
| 254 | return nil | 265 | return nil | ||
| 255 | }, | 266 | }, | ||
| 256 | }) | 267 | }) | ||
| 257 | - | _, cancel := context.WithCancel(context.Background()) | |||
| 258 | interactive.mu.Lock() | 268 | interactive.mu.Lock() | ||
| 259 | interactive.busy = true | 269 | interactive.busy = true | ||
| 260 | - | interactive.cancelTurn = cancel | 270 | + | interactive.cancelTurn = func() { close(cancelled) } |
| 261 | interactive.mu.Unlock() | 271 | interactive.mu.Unlock() | ||
| 262 | 272 | ||||
| 263 | - | interactive.CancelTurn() | 273 | + | done := make(chan struct{}) |
| 274 | + | go func() { | |||
| 275 | + | interactive.CancelTurn() | |||
| 276 | + | close(done) | |||
| 277 | + | }() | |||
| 278 | + | ||||
| 279 | + | select { | |||
| 280 | + | case <-cancelled: | |||
| 281 | + | case <-time.After(2 * time.Second): | |||
| 282 | + | t.Fatal("cancellation waited for compact handoff persistence") | |||
| 283 | + | } | |||
| 284 | + | select { | |||
| 285 | + | case <-persistenceStarted: | |||
| 286 | + | case <-time.After(2 * time.Second): | |||
| 287 | + | t.Fatal("cancellation did not persist the cleared compact handoff") | |||
| 288 | + | } | |||
| 289 | + | close(releasePersistence) | |||
| 290 | + | released = true | |||
| 291 | + | <-done | |||
| 264 | select { | 292 | select { | ||
| 265 | case state := <-persisted: | 293 | case state := <-persisted: | ||
| 266 | if state != "" { | 294 | if state != "" { | ||
| 267 | t.Fatalf("cleared compact handoff = %q, want empty", state) | 295 | t.Fatalf("cleared compact handoff = %q, want empty", state) | ||
| 268 | } | 296 | } | ||
| 269 | case <-time.After(2 * time.Second): | 297 | case <-time.After(2 * time.Second): | ||
| 270 | - | t.Fatal("cancellation did not clear the persisted compact handoff") | 298 | + | t.Fatal("cancellation did not finish compact handoff persistence") |
| 271 | } | 299 | } | ||
| 272 | } | 300 | } | ||
| 273 | 301 | ||||
M packages/agent/modes/compact_queue_test.go +146 -2
| @@ -293,6 +293,34 @@ func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) | |||
| 293 | 293 | return out, nil | |
| 294 | 294 | } | |
| 295 | 295 | ||
| 296 | + | type heldCompletionClient struct { | |
| 297 | + | started chan struct{} | |
| 298 | + | release chan struct{} | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func (c *heldCompletionClient) Name() string { return "held-completion-test" } | |
| 302 | + | ||
| 303 | + | func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 304 | + | out := make(chan provider.Event, 1) | |
| 305 | + | go func() { | |
| 306 | + | defer close(out) | |
| 307 | + | close(c.started) | |
| 308 | + | select { | |
| 309 | + | case <-ctx.Done(): | |
| 310 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |
| 311 | + | case <-c.release: | |
| 312 | + | out <- provider.EventDone{ | |
| 313 | + | Stop: provider.StopEnd, | |
| 314 | + | Message: provider.Message{ | |
| 315 | + | Role: provider.RoleAssistant, | |
| 316 | + | Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}}, | |
| 317 | + | }, | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | }() | |
| 321 | + | return out, nil | |
| 322 | + | } | |
| 323 | + | ||
| 296 | 324 | func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) { | |
| 297 | 325 | client := &restoredHandoffClient{ | |
| 298 | 326 | requests: make(chan provider.Request, 2), | |
| @@ -364,12 +392,21 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |||
| 364 | 392 | Agent: agent, | |
| 365 | 393 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 366 | 394 | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 367 | - | persisted <- string(state) | |
| 395 | + | select { | |
| 396 | + | case persisted <- string(state): | |
| 397 | + | default: | |
| 398 | + | } | |
| 368 | 399 | return nil | |
| 369 | 400 | }, | |
| 370 | 401 | }) | |
| 371 | 402 | ||
| 372 | 403 | interactive.startRestoredCompactHandoff(context.Background()) | |
| 404 | + | interactive.mu.Lock() | |
| 405 | + | busy := interactive.busy | |
| 406 | + | interactive.mu.Unlock() | |
| 407 | + | if busy { | |
| 408 | + | t.Fatal("discarded restored handoff left the turn slot reserved") | |
| 409 | + | } | |
| 373 | 410 | select { | |
| 374 | 411 | case state := <-persisted: | |
| 375 | 412 | if state != "" { | |
| @@ -385,6 +422,110 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |||
| 385 | 422 | } | |
| 386 | 423 | } | |
| 387 | 424 | ||
| 425 | + | func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) { | |
| 426 | + | client := &restoredHandoffClient{ | |
| 427 | + | requests: make(chan provider.Request, 1), | |
| 428 | + | replies: []string{"All required work is complete."}, | |
| 429 | + | } | |
| 430 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 431 | + | agent.SetMessages([]provider.Message{{ | |
| 432 | + | Role: provider.RoleUser, | |
| 433 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 434 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 435 | + | }}) | |
| 436 | + | persistenceStarted := make(chan struct{}) | |
| 437 | + | releasePersistence := make(chan struct{}) | |
| 438 | + | released := false | |
| 439 | + | defer func() { | |
| 440 | + | if !released { | |
| 441 | + | close(releasePersistence) | |
| 442 | + | } | |
| 443 | + | }() | |
| 444 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 445 | + | Agent: agent, | |
| 446 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 447 | + | PersistCompactHandoff: func(json.RawMessage) error { | |
| 448 | + | close(persistenceStarted) | |
| 449 | + | <-releasePersistence | |
| 450 | + | return nil | |
| 451 | + | }, | |
| 452 | + | }) | |
| 453 | + | interactive.runCtx = context.Background() | |
| 454 | + | interactive.mu.Lock() | |
| 455 | + | interactive.queued = []string{"newer user request"} | |
| 456 | + | interactive.mu.Unlock() | |
| 457 | + | ||
| 458 | + | done := make(chan struct{}) | |
| 459 | + | go func() { | |
| 460 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 461 | + | close(done) | |
| 462 | + | }() | |
| 463 | + | select { | |
| 464 | + | case <-persistenceStarted: | |
| 465 | + | case <-time.After(2 * time.Second): | |
| 466 | + | t.Fatal("restored handoff did not reach persistence") | |
| 467 | + | } | |
| 468 | + | interactive.mu.Lock() | |
| 469 | + | busy := interactive.busy | |
| 470 | + | interactive.mu.Unlock() | |
| 471 | + | if !busy { | |
| 472 | + | t.Fatal("restored handoff did not reserve the turn slot before persistence") | |
| 473 | + | } | |
| 474 | + | close(releasePersistence) | |
| 475 | + | released = true | |
| 476 | + | <-done | |
| 477 | + | select { | |
| 478 | + | case <-client.requests: | |
| 479 | + | case <-time.After(2 * time.Second): | |
| 480 | + | t.Fatal("restored handoff did not continue after persistence") | |
| 481 | + | } | |
| 482 | + | waitInteractiveIdle(t, interactive) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) { | |
| 486 | + | client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})} | |
| 487 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 488 | + | agent.SetMessages([]provider.Message{{ | |
| 489 | + | Role: provider.RoleUser, | |
| 490 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 491 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 492 | + | }}) | |
| 493 | + | persisted := make(chan string, 1) | |
| 494 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 495 | + | Agent: agent, | |
| 496 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 497 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 498 | + | select { | |
| 499 | + | case persisted <- string(state): | |
| 500 | + | default: | |
| 501 | + | } | |
| 502 | + | return nil | |
| 503 | + | }, | |
| 504 | + | }) | |
| 505 | + | interactive.runCtx = context.Background() | |
| 506 | + | ||
| 507 | + | interactive.startTurnRequest(context.Background(), "", nil, true, false) | |
| 508 | + | select { | |
| 509 | + | case <-client.started: | |
| 510 | + | case <-time.After(2 * time.Second): | |
| 511 | + | t.Fatal("continuation did not start") | |
| 512 | + | } | |
| 513 | + | interactive.mu.Lock() | |
| 514 | + | interactive.agent = nil | |
| 515 | + | interactive.mu.Unlock() | |
| 516 | + | close(client.release) | |
| 517 | + | ||
| 518 | + | select { | |
| 519 | + | case state := <-persisted: | |
| 520 | + | if state != "" { | |
| 521 | + | t.Fatalf("cleared handoff = %q, want empty", state) | |
| 522 | + | } | |
| 523 | + | case <-time.After(2 * time.Second): | |
| 524 | + | t.Fatal("agent removal did not clear the persisted handoff") | |
| 525 | + | } | |
| 526 | + | waitInteractiveIdle(t, interactive) | |
| 527 | + | } | |
| 528 | + | ||
| 388 | 529 | func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | |
| 389 | 530 | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |
| 390 | 531 | agent := core.NewAgent(client, "test-model", "", nil) | |
| @@ -503,7 +644,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||
| 503 | 644 | Model: "claude-sonnet-4-5-20250929", | |
| 504 | 645 | AutoCompactThreshold: &threshold, | |
| 505 | 646 | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 506 | - | persistedHandoff <- string(state) | |
| 647 | + | select { | |
| 648 | + | case persistedHandoff <- string(state): | |
| 649 | + | default: | |
| 650 | + | } | |
| 507 | 651 | return nil | |
| 508 | 652 | }, | |
| 509 | 653 | }) | |
| @@ -293,6 +293,34 @@ func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) | |||||
| 293 | return out, nil | 293 | return out, nil | ||
| 294 | } | 294 | } | ||
| 295 | 295 | ||||
| 296 | + | type heldCompletionClient struct { | |||
| 297 | + | started chan struct{} | |||
| 298 | + | release chan struct{} | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func (c *heldCompletionClient) Name() string { return "held-completion-test" } | |||
| 302 | + | ||||
| 303 | + | func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 304 | + | out := make(chan provider.Event, 1) | |||
| 305 | + | go func() { | |||
| 306 | + | defer close(out) | |||
| 307 | + | close(c.started) | |||
| 308 | + | select { | |||
| 309 | + | case <-ctx.Done(): | |||
| 310 | + | out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()} | |||
| 311 | + | case <-c.release: | |||
| 312 | + | out <- provider.EventDone{ | |||
| 313 | + | Stop: provider.StopEnd, | |||
| 314 | + | Message: provider.Message{ | |||
| 315 | + | Role: provider.RoleAssistant, | |||
| 316 | + | Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}}, | |||
| 317 | + | }, | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | }() | |||
| 321 | + | return out, nil | |||
| 322 | + | } | |||
| 323 | + | ||||
| 296 | func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) { | 324 | func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) { | ||
| 297 | client := &restoredHandoffClient{ | 325 | client := &restoredHandoffClient{ | ||
| 298 | requests: make(chan provider.Request, 2), | 326 | requests: make(chan provider.Request, 2), | ||
| @@ -364,12 +392,21 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |||||
| 364 | Agent: agent, | 392 | Agent: agent, | ||
| 365 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | 393 | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | ||
| 366 | PersistCompactHandoff: func(state json.RawMessage) error { | 394 | PersistCompactHandoff: func(state json.RawMessage) error { | ||
| 367 | - | persisted <- string(state) | 395 | + | select { |
| 396 | + | case persisted <- string(state): | |||
| 397 | + | default: | |||
| 398 | + | } | |||
| 368 | return nil | 399 | return nil | ||
| 369 | }, | 400 | }, | ||
| 370 | }) | 401 | }) | ||
| 371 | 402 | ||||
| 372 | interactive.startRestoredCompactHandoff(context.Background()) | 403 | interactive.startRestoredCompactHandoff(context.Background()) | ||
| 404 | + | interactive.mu.Lock() | |||
| 405 | + | busy := interactive.busy | |||
| 406 | + | interactive.mu.Unlock() | |||
| 407 | + | if busy { | |||
| 408 | + | t.Fatal("discarded restored handoff left the turn slot reserved") | |||
| 409 | + | } | |||
| 373 | select { | 410 | select { | ||
| 374 | case state := <-persisted: | 411 | case state := <-persisted: | ||
| 375 | if state != "" { | 412 | if state != "" { | ||
| @@ -385,6 +422,110 @@ func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |||||
| 385 | } | 422 | } | ||
| 386 | } | 423 | } | ||
| 387 | 424 | ||||
| 425 | + | func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) { | |||
| 426 | + | client := &restoredHandoffClient{ | |||
| 427 | + | requests: make(chan provider.Request, 1), | |||
| 428 | + | replies: []string{"All required work is complete."}, | |||
| 429 | + | } | |||
| 430 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 431 | + | agent.SetMessages([]provider.Message{{ | |||
| 432 | + | Role: provider.RoleUser, | |||
| 433 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 434 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 435 | + | }}) | |||
| 436 | + | persistenceStarted := make(chan struct{}) | |||
| 437 | + | releasePersistence := make(chan struct{}) | |||
| 438 | + | released := false | |||
| 439 | + | defer func() { | |||
| 440 | + | if !released { | |||
| 441 | + | close(releasePersistence) | |||
| 442 | + | } | |||
| 443 | + | }() | |||
| 444 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 445 | + | Agent: agent, | |||
| 446 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 447 | + | PersistCompactHandoff: func(json.RawMessage) error { | |||
| 448 | + | close(persistenceStarted) | |||
| 449 | + | <-releasePersistence | |||
| 450 | + | return nil | |||
| 451 | + | }, | |||
| 452 | + | }) | |||
| 453 | + | interactive.runCtx = context.Background() | |||
| 454 | + | interactive.mu.Lock() | |||
| 455 | + | interactive.queued = []string{"newer user request"} | |||
| 456 | + | interactive.mu.Unlock() | |||
| 457 | + | ||||
| 458 | + | done := make(chan struct{}) | |||
| 459 | + | go func() { | |||
| 460 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 461 | + | close(done) | |||
| 462 | + | }() | |||
| 463 | + | select { | |||
| 464 | + | case <-persistenceStarted: | |||
| 465 | + | case <-time.After(2 * time.Second): | |||
| 466 | + | t.Fatal("restored handoff did not reach persistence") | |||
| 467 | + | } | |||
| 468 | + | interactive.mu.Lock() | |||
| 469 | + | busy := interactive.busy | |||
| 470 | + | interactive.mu.Unlock() | |||
| 471 | + | if !busy { | |||
| 472 | + | t.Fatal("restored handoff did not reserve the turn slot before persistence") | |||
| 473 | + | } | |||
| 474 | + | close(releasePersistence) | |||
| 475 | + | released = true | |||
| 476 | + | <-done | |||
| 477 | + | select { | |||
| 478 | + | case <-client.requests: | |||
| 479 | + | case <-time.After(2 * time.Second): | |||
| 480 | + | t.Fatal("restored handoff did not continue after persistence") | |||
| 481 | + | } | |||
| 482 | + | waitInteractiveIdle(t, interactive) | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) { | |||
| 486 | + | client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})} | |||
| 487 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 488 | + | agent.SetMessages([]provider.Message{{ | |||
| 489 | + | Role: provider.RoleUser, | |||
| 490 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 491 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 492 | + | }}) | |||
| 493 | + | persisted := make(chan string, 1) | |||
| 494 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 495 | + | Agent: agent, | |||
| 496 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 497 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |||
| 498 | + | select { | |||
| 499 | + | case persisted <- string(state): | |||
| 500 | + | default: | |||
| 501 | + | } | |||
| 502 | + | return nil | |||
| 503 | + | }, | |||
| 504 | + | }) | |||
| 505 | + | interactive.runCtx = context.Background() | |||
| 506 | + | ||||
| 507 | + | interactive.startTurnRequest(context.Background(), "", nil, true, false) | |||
| 508 | + | select { | |||
| 509 | + | case <-client.started: | |||
| 510 | + | case <-time.After(2 * time.Second): | |||
| 511 | + | t.Fatal("continuation did not start") | |||
| 512 | + | } | |||
| 513 | + | interactive.mu.Lock() | |||
| 514 | + | interactive.agent = nil | |||
| 515 | + | interactive.mu.Unlock() | |||
| 516 | + | close(client.release) | |||
| 517 | + | ||||
| 518 | + | select { | |||
| 519 | + | case state := <-persisted: | |||
| 520 | + | if state != "" { | |||
| 521 | + | t.Fatalf("cleared handoff = %q, want empty", state) | |||
| 522 | + | } | |||
| 523 | + | case <-time.After(2 * time.Second): | |||
| 524 | + | t.Fatal("agent removal did not clear the persisted handoff") | |||
| 525 | + | } | |||
| 526 | + | waitInteractiveIdle(t, interactive) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 388 | func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | 529 | func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | ||
| 389 | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | 530 | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | ||
| 390 | agent := core.NewAgent(client, "test-model", "", nil) | 531 | agent := core.NewAgent(client, "test-model", "", nil) | ||
| @@ -503,7 +644,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||||
| 503 | Model: "claude-sonnet-4-5-20250929", | 644 | Model: "claude-sonnet-4-5-20250929", | ||
| 504 | AutoCompactThreshold: &threshold, | 645 | AutoCompactThreshold: &threshold, | ||
| 505 | PersistCompactHandoff: func(state json.RawMessage) error { | 646 | PersistCompactHandoff: func(state json.RawMessage) error { | ||
| 506 | - | persistedHandoff <- string(state) | 647 | + | select { |
| 648 | + | case persistedHandoff <- string(state): | |||
| 649 | + | default: | |||
| 650 | + | } | |||
| 507 | return nil | 651 | return nil | ||
| 508 | }, | 652 | }, | ||
| 509 | }) | 653 | }) | ||
M packages/agent/modes/interactive.go +20 -11
| @@ -3159,15 +3159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3159 | 3159 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3160 | 3160 | } | |
| 3161 | 3161 | i.mu.Unlock() | |
| 3162 | - | if persistHandoff { | |
| 3163 | - | i.persistCompactHandoff(handoff) | |
| 3164 | - | } | |
| 3165 | 3162 | if busyCancel { | |
| 3166 | 3163 | cancelTurn() | |
| 3167 | 3164 | // If a confirm dialog is pending, refuse it so the agent | |
| 3168 | 3165 | // goroutine unblocks and the context cancellation can | |
| 3169 | 3166 | // actually take effect. | |
| 3170 | 3167 | i.confirmDialog.CancelAll("turn cancelled") | |
| 3168 | + | if persistHandoff { | |
| 3169 | + | i.persistCompactHandoff(handoff) | |
| 3170 | + | } | |
| 3171 | 3171 | return false | |
| 3172 | 3172 | } | |
| 3173 | 3173 | case tui.KeyCtrlD: | |
| @@ -4036,11 +4036,11 @@ func (i *Interactive) CancelTurn() { | |||
| 4036 | 4036 | i.mu.Lock() | |
| 4037 | 4037 | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 4038 | 4038 | i.mu.Unlock() | |
| 4039 | + | cancel() | |
| 4040 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 4039 | 4041 | if persistHandoff { | |
| 4040 | 4042 | i.persistCompactHandoff(handoff) | |
| 4041 | 4043 | } | |
| 4042 | - | cancel() | |
| 4043 | - | i.confirmDialog.CancelAll("turn cancelled") | |
| 4044 | 4044 | } | |
| 4045 | 4045 | } | |
| 4046 | 4046 | ||
| @@ -6413,10 +6413,10 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||
| 6413 | 6413 | i.mu.Lock() | |
| 6414 | 6414 | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 6415 | 6415 | i.mu.Unlock() | |
| 6416 | + | cancel() | |
| 6416 | 6417 | if persistHandoff { | |
| 6417 | 6418 | i.persistCompactHandoff(handoff) | |
| 6418 | 6419 | } | |
| 6419 | - | cancel() | |
| 6420 | 6420 | } | |
| 6421 | 6421 | // ConfirmToolCall waits on its response channel rather than the turn | |
| 6422 | 6422 | // context, so cancellation must also resolve every pending prompt. | |
| @@ -7026,10 +7026,18 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 7026 | 7026 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7027 | 7027 | } | |
| 7028 | 7028 | } | |
| 7029 | + | // Reserve the turn slot before persisting or dispatching so concurrent input | |
| 7030 | + | // can only queue behind the restored handoff, never start a competing turn. | |
| 7031 | + | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |
| 7032 | + | i.busy = starting | |
| 7029 | 7033 | i.mu.Unlock() | |
| 7030 | 7034 | if persistHandoff { | |
| 7031 | 7035 | i.persistCompactHandoff(handoff) | |
| 7032 | 7036 | } | |
| 7037 | + | if !starting { | |
| 7038 | + | i.invalidate() | |
| 7039 | + | return | |
| 7040 | + | } | |
| 7033 | 7041 | switch { | |
| 7034 | 7042 | case next != "": | |
| 7035 | 7043 | i.startTurn(parent, next) | |
| @@ -7661,7 +7669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7661 | 7669 | continueStatusRescue := false | |
| 7662 | 7670 | var handoff json.RawMessage | |
| 7663 | 7671 | var persistHandoff bool | |
| 7664 | - | if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 7672 | + | if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 7665 | 7673 | followUpMessages := i.agent.Messages() | |
| 7666 | 7674 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | |
| 7667 | 7675 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | |
| @@ -7674,7 +7682,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7674 | 7682 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7675 | 7683 | } | |
| 7676 | 7684 | } | |
| 7677 | - | if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 7685 | + | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 7678 | 7686 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7679 | 7687 | } | |
| 7680 | 7688 | // The agent run can finish before the paced final text reaches the | |
| @@ -9362,9 +9370,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 9362 | 9370 | i.statusErr = "" | |
| 9363 | 9371 | i.mu.Unlock() | |
| 9364 | 9372 | i.scrollToBottom() | |
| 9365 | - | // Selection only changes local/session state. In particular, do not call | |
| 9366 | - | // Submit, startTurn, or queue a message here; the restored user draft is | |
| 9367 | - | // submitted only by a later explicit Enter. | |
| 9373 | + | // Selection does not submit, start, or queue the restored user draft; only | |
| 9374 | + | // a later explicit Enter does that. An inherited compact handoff may resume | |
| 9375 | + | // its hidden continuation below when this branch has the complete effective | |
| 9376 | + | // transcript, without submitting the restored draft. | |
| 9368 | 9377 | i.invalidate() | |
| 9369 | 9378 | if state.reason != compactContinuationNone { | |
| 9370 | 9379 | i.startRestoredCompactHandoff(i.runCtx) | |
| @@ -3159,15 +3159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3159 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 3159 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 3160 | } | 3160 | } | ||
| 3161 | i.mu.Unlock() | 3161 | i.mu.Unlock() | ||
| 3162 | - | if persistHandoff { | |||
| 3163 | - | i.persistCompactHandoff(handoff) | |||
| 3164 | - | } | |||
| 3165 | if busyCancel { | 3162 | if busyCancel { | ||
| 3166 | cancelTurn() | 3163 | cancelTurn() | ||
| 3167 | // If a confirm dialog is pending, refuse it so the agent | 3164 | // If a confirm dialog is pending, refuse it so the agent | ||
| 3168 | // goroutine unblocks and the context cancellation can | 3165 | // goroutine unblocks and the context cancellation can | ||
| 3169 | // actually take effect. | 3166 | // actually take effect. | ||
| 3170 | i.confirmDialog.CancelAll("turn cancelled") | 3167 | i.confirmDialog.CancelAll("turn cancelled") | ||
| 3168 | + | if persistHandoff { | |||
| 3169 | + | i.persistCompactHandoff(handoff) | |||
| 3170 | + | } | |||
| 3171 | return false | 3171 | return false | ||
| 3172 | } | 3172 | } | ||
| 3173 | case tui.KeyCtrlD: | 3173 | case tui.KeyCtrlD: | ||
| @@ -4036,11 +4036,11 @@ func (i *Interactive) CancelTurn() { | |||||
| 4036 | i.mu.Lock() | 4036 | i.mu.Lock() | ||
| 4037 | handoff, persistHandoff := i.resetCompactContinuationLocked() | 4037 | handoff, persistHandoff := i.resetCompactContinuationLocked() | ||
| 4038 | i.mu.Unlock() | 4038 | i.mu.Unlock() | ||
| 4039 | + | cancel() | |||
| 4040 | + | i.confirmDialog.CancelAll("turn cancelled") | |||
| 4039 | if persistHandoff { | 4041 | if persistHandoff { | ||
| 4040 | i.persistCompactHandoff(handoff) | 4042 | i.persistCompactHandoff(handoff) | ||
| 4041 | } | 4043 | } | ||
| 4042 | - | cancel() | |||
| 4043 | - | i.confirmDialog.CancelAll("turn cancelled") | |||
| 4044 | } | 4044 | } | ||
| 4045 | } | 4045 | } | ||
| 4046 | 4046 | ||||
| @@ -6413,10 +6413,10 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||||
| 6413 | i.mu.Lock() | 6413 | i.mu.Lock() | ||
| 6414 | handoff, persistHandoff := i.resetCompactContinuationLocked() | 6414 | handoff, persistHandoff := i.resetCompactContinuationLocked() | ||
| 6415 | i.mu.Unlock() | 6415 | i.mu.Unlock() | ||
| 6416 | + | cancel() | |||
| 6416 | if persistHandoff { | 6417 | if persistHandoff { | ||
| 6417 | i.persistCompactHandoff(handoff) | 6418 | i.persistCompactHandoff(handoff) | ||
| 6418 | } | 6419 | } | ||
| 6419 | - | cancel() | |||
| 6420 | } | 6420 | } | ||
| 6421 | // ConfirmToolCall waits on its response channel rather than the turn | 6421 | // ConfirmToolCall waits on its response channel rather than the turn | ||
| 6422 | // context, so cancellation must also resolve every pending prompt. | 6422 | // context, so cancellation must also resolve every pending prompt. | ||
| @@ -7026,10 +7026,18 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 7026 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 7026 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 7027 | } | 7027 | } | ||
| 7028 | } | 7028 | } | ||
| 7029 | + | // Reserve the turn slot before persisting or dispatching so concurrent input | |||
| 7030 | + | // can only queue behind the restored handoff, never start a competing turn. | |||
| 7031 | + | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |||
| 7032 | + | i.busy = starting | |||
| 7029 | i.mu.Unlock() | 7033 | i.mu.Unlock() | ||
| 7030 | if persistHandoff { | 7034 | if persistHandoff { | ||
| 7031 | i.persistCompactHandoff(handoff) | 7035 | i.persistCompactHandoff(handoff) | ||
| 7032 | } | 7036 | } | ||
| 7037 | + | if !starting { | |||
| 7038 | + | i.invalidate() | |||
| 7039 | + | return | |||
| 7040 | + | } | |||
| 7033 | switch { | 7041 | switch { | ||
| 7034 | case next != "": | 7042 | case next != "": | ||
| 7035 | i.startTurn(parent, next) | 7043 | i.startTurn(parent, next) | ||
| @@ -7661,7 +7669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7661 | continueStatusRescue := false | 7669 | continueStatusRescue := false | ||
| 7662 | var handoff json.RawMessage | 7670 | var handoff json.RawMessage | ||
| 7663 | var persistHandoff bool | 7671 | var persistHandoff bool | ||
| 7664 | - | if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | 7672 | + | if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { |
| 7665 | followUpMessages := i.agent.Messages() | 7673 | followUpMessages := i.agent.Messages() | ||
| 7666 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | 7674 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | ||
| 7667 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | 7675 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | ||
| @@ -7674,7 +7682,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7674 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 7682 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 7675 | } | 7683 | } | ||
| 7676 | } | 7684 | } | ||
| 7677 | - | if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | 7685 | + | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { |
| 7678 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 7686 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 7679 | } | 7687 | } | ||
| 7680 | // The agent run can finish before the paced final text reaches the | 7688 | // The agent run can finish before the paced final text reaches the | ||
| @@ -9362,9 +9370,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||||
| 9362 | i.statusErr = "" | 9370 | i.statusErr = "" | ||
| 9363 | i.mu.Unlock() | 9371 | i.mu.Unlock() | ||
| 9364 | i.scrollToBottom() | 9372 | i.scrollToBottom() | ||
| 9365 | - | // Selection only changes local/session state. In particular, do not call | 9373 | + | // Selection does not submit, start, or queue the restored user draft; only |
| 9366 | - | // Submit, startTurn, or queue a message here; the restored user draft is | 9374 | + | // a later explicit Enter does that. An inherited compact handoff may resume |
| 9367 | - | // submitted only by a later explicit Enter. | 9375 | + | // its hidden continuation below when this branch has the complete effective |
| 9376 | + | // transcript, without submitting the restored draft. | |||
| 9368 | i.invalidate() | 9377 | i.invalidate() | ||
| 9369 | if state.reason != compactContinuationNone { | 9378 | if state.reason != compactContinuationNone { | ||
| 9370 | i.startRestoredCompactHandoff(i.runCtx) | 9379 | i.startRestoredCompactHandoff(i.runCtx) | ||