fix(control): strip incomplete turn messages after user cancel (#5286)#5303
Conversation
…ine#5286) When the user presses Ctrl+C to cancel a turn, the agent's incomplete assistant messages and tool results are already saved to the session. On the next turn, the model sees leftover in-progress todo items and partial tool calls and may re-execute the interrupted work. Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run() returns context.Canceled AND the cancel was user-initiated (CancelRequested() == true), strip the session back to the pre-turn message count so the next prompt starts clean. The guard (CancelRequested) ensures internal context cancellations (stream recovery, timeouts) are unaffected — only explicit user cancels trigger the strip.
SivanCola
left a comment
There was a problem hiding this comment.
Thanks, this is directionally right and the mechanism is sound. The CancelRequested() guard is the correct discriminator, and adding the strip in both the first-run and plan-approved-execution branches is the right scope. Before merging, please address two incomplete contracts:
[P1] todoState is not reset after the strip
stripTurnMessagesAfter truncates the transcript via Session.Replace, but Agent.todoState is independent in-memory state. Session.Replace does not call rebuildTodoState — that only fires from SetSession (session pointer swap) and compaction. So after a cancelled turn that had a successful todo_write, the next call to Controller.Todos(), goalTodos(), goal readiness checks, and the task panel still see the stale in_progress item from the stripped turn.
The fix: after stripTurnMessagesAfter, call rebuildTodoState (or an equivalent public wrapper) on the truncated message slice so todoState is consistent with the transcript. For the plan-approved-execution branch, restore to the post-seed state (after seedPlanTodos) rather than the pre-plan state — the seed's todos are legitimate, only the execution leftovers should go.
[P2] A mid-turn autosave can leave stale bytes on disk
autosaveWhileRunning snapshots to disk periodically during a turn. snapshotActivityIfChanged(startMessages) is deferred, but its guard is messageCount() > startMessages; after the strip, the count is back to startMessages, so the defer exits early without writing the cleaned transcript. A restart or session resume will reload the stale file and the model will see the partial turn again.
The fix: after stripping, call SnapshotActivity() (or snapshot(true)) unconditionally to flush the cleaned transcript to disk. Make sure the "new session with only a system message" edge case (where HasContent() would return false) is also covered, since a cancelled first-turn leaves a session file with only the partial turn stripped away.
What already works well:
- The
CancelRequested()guard correctly limits the strip to explicit user cancels, leaving internal timeouts and stream-recovery cancels unaffected. - Adding the strip in both the first-run branch and the plan-approved-execution branch is the right scope.
stripTurnMessagesAfteritself is correct and safe.- The test accurately captures the transcript strip. Once P1 is addressed, add a parallel assertion that
c.Todos()(or the agent'sCanonicalTodoState()) returns the pre-turn list, and that a session reload yields the clean transcript (P2).
Once these are addressed and CI stays green, I will consider merging this into main-v2.
After stripTurnMessagesAfter truncates the session transcript, two states remained inconsistent: 1. Agent.todoState was not updated. Session.Replace only swaps the message slice; it does not call rebuildTodoState. So a cancelled turn that had a successful todo_write left the in_progress item in the canonical task list, visible via Controller.Todos(), goal readiness, and the task panel on the next turn. Fix: expose Agent.RebuildTodoState() and call it after Replace. 2. A mid-turn autosave may have already written the partial transcript to disk. snapshotActivityIfChanged skips the write when messageCount() returns to startMessages (which it does after the strip), so the stale file was never overwritten. A restart or session resume would reload the partial turn. Fix: call Session.Save(path) directly after the strip to force a flush, bypassing the HasContent() guard that snapshot() uses (so the clean state is written even when the strip leaves only a system message). Tests: extend TestTurnOrchestratorCancelStripsIncompleteTurn to assert Todos() is empty after cancel, and add TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk to verify the on-disk transcript matches the pre-cancel message count. Co-authored-by: SivanCola <32437197+SivanCola@users.noreply.github.com>
SivanCola
left a comment
There was a problem hiding this comment.
Both issues are addressed in commit 65f18ea. Here is what changed:
P1 — todoState now rebuilt after strip
Added Agent.RebuildTodoState() (a thin public wrapper over the existing rebuildTodoState internal method) and call it in stripTurnMessagesAfter immediately after Session.Replace. The rebuild derives canonical state from the truncated transcript, so Controller.Todos(), goal readiness, and the task panel see the correct (empty) state after a user cancel.
The test TestTurnOrchestratorCancelStripsIncompleteTurn now pre-seeds the executor's todoState via ReplaceTodoState (simulating a successful todo_write mid-turn) and asserts c.Todos() returns empty after the cancel strip.
P2 — disk flushed after strip
Added Session.Save(path) directly after the strip in stripTurnMessagesAfter. This bypasses the HasContent() guard in snapshot() so the clean transcript is written even when the strip leaves only a system message. The snapshotActivityIfChanged guard (messageCount() > startMessages) would have skipped the write because the count is back to startMessages after stripping.
Added TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk: it spins up a controller with a real session path, runs a cancel, then reloads the file with LoadSession and asserts the on-disk non-system message count matches the pre-cancel count.
Verified:
go test -race -run 'TestTurnOrchestratorCancelStripsIncompleteTurn|TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk|TestTurnOrchestratorStopHookCancelledContext|TestTurnOrchestratorApprovedPlanSharesOneStopHook' ./internal/control/passedgo test ./internal/agent/ ./internal/control/passed (both packages, no -race regressions)- Failure-mode verified: P1 test fails without
RebuildTodoState, P2 test fails withoutSession.Save gofmt -lclean,git diff --checkclean
When the user presses Ctrl+C to cancel a turn, the agent's incomplete assistant messages and tool results are already saved to the session. On the next turn, the model sees leftover in-progress todo items and partial tool calls and may re-execute the interrupted work.
Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run() returns context.Canceled AND the cancel was user-initiated (CancelRequested() == true), strip the session back to the pre-turn message count so the next prompt starts clean.
The guard (CancelRequested) ensures internal context cancellations (stream recovery, timeouts) are unaffected — only explicit user cancels trigger the strip.
Summary
Verification
go test -race -run 'TestTurnOrchestratorCancelStripsIncompleteTurn|TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk' ./internal/control/passesgo test ./internal/agent/ ./internal/control/passes (both packages, no regressions)Cache impact
Cache-impact: none - The change to internal/agent/agent.go only adds a public wrapper method RebuildTodoState() that calls the existing private rebuildTodoState(). It does not touch system prompt, tool list, tool schemas, provider serialization, compaction, or reasoning upload. No provider-visible prefix changes.
Cache-guard: go test ./internal/agent/ ./internal/control/ — existing schema stability tests pass; no cache-sensitive surface is touched.
System-prompt-review: N/A