fix(tui): drop stale stream events after ctrl-c interrupt (#16706)

* fix(tui): drop stale stream events after ctrl-c interrupt

Once interruptTurn() flips this.interrupted, only recordMessageDelta
short-circuited.  recordReasoningDelta/Available, recordToolStart/
Progress/Complete, and recordInlineDiffToolComplete kept populating
turnState until the python loop reached its next _interrupt_requested
check (~1s on busy turns), making it look like ctrl-c was ignored
while late "thinking" + tool calls kept landing in the UI.

Add the same interrupted guard to every stream-side recorder, and
clear the flag at startMessage() so the next turn isn't suppressed
if the previous turn never delivered message.complete.

* fix(tui): guard recordTodos against post-interrupt mutation; fake-timers in test

Copilot review on PR #16706:

1. `recordToolStart` is interruption-guarded, but `tool.start`
   handler also calls `recordTodos(payload.todos)` first — so a
   late tool.start carrying todos could still mutate `turnState.todos`
   after Ctrl-C, leaving ghost rows in the panel.  Adds the same
   `if (this.interrupted) return` early-exit to `recordTodos` so
   *all* tool.start side-effects are dropped post-interrupt.

2. The interrupt test was leaking a real `setTimeout` (interrupt
   cooldown) across test files, which could fire later and mutate
   uiStore from the wrong test context.  Wraps the test in
   `vi.useFakeTimers()` + `vi.runAllTimers()` and restores real
   timers in finally.

3. Extends the same test with a todos payload on the post-interrupt
   tool.start so we have explicit regression coverage for #1.

* fix(tui): guard pushTrail post-interrupt; harden interrupt-test cleanup

Round 2 Copilot review on PR #16706:

1. `tool.generating` events route through `pushTrail`, which was not
   interruption-guarded — late events could still write 'drafting …'
   into `turnTrail` after Ctrl-C, leaving a stale shimmer in the UI.
   Adds the same `if (this.interrupted) return` early-exit.

2. Test cleanup moved `vi.runAllTimers()` into `finally` (before
   `vi.useRealTimers()`) so a mid-test assertion failure can't leak
   the interrupt-cooldown setTimeout across other test files.

3. Replaced the misleading 'pre-interrupt todos … expected to be
   cleared by the interrupt cycle' comment with an accurate one
   reflecting current behaviour (interrupt does NOT clear todos).

4. Added an explicit assertion that a post-interrupt `tool.generating`
   event does not extend `turnTrail` — regression coverage for #1.
This commit is contained in:
brooklyn! 2026-04-28 14:51:07 -07:00 committed by GitHub
parent a830f25f71
commit e42065b1f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 6 deletions

View file

@ -316,6 +316,10 @@ class TurnController {
}
recordTodos(value: unknown) {
if (this.interrupted) {
return
}
const todos = parseTodos(value)
if (todos !== null) {
@ -397,6 +401,10 @@ class TurnController {
}
pushTrail(line: string) {
if (this.interrupted) {
return
}
patchTurnState(state => {
if (state.turnTrail.at(-1) === line) {
return state
@ -509,13 +517,13 @@ class TurnController {
}
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
this.pruneTransient()
this.endReasoningPhase()
if (!text || this.interrupted) {
if (this.interrupted || !text) {
return
}
this.pruneTransient()
this.endReasoningPhase()
this.bufRef = rendered ?? this.bufRef + text
if (getUiState().streaming) {
@ -524,7 +532,7 @@ class TurnController {
}
recordReasoningAvailable(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}
@ -542,7 +550,7 @@ class TurnController {
}
recordReasoningDelta(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}
@ -570,6 +578,10 @@ class TurnController {
duration?: number,
todos?: unknown
) {
if (this.interrupted) {
return
}
this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration)
@ -585,6 +597,10 @@ class TurnController {
error?: string,
duration?: number
) {
if (this.interrupted) {
return
}
this.flushStreamingSegment()
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)])
this.publishToolState()
@ -626,6 +642,10 @@ class TurnController {
}
recordToolProgress(toolName: string, preview: string) {
if (this.interrupted) {
return
}
const index = this.activeTools.findIndex(tool => tool.name === toolName)
if (index < 0) {
@ -645,6 +665,10 @@ class TurnController {
}
recordToolStart(toolId: string, name: string, context: string) {
if (this.interrupted) {
return
}
this.flushStreamingSegment()
this.closeReasoningSegment()
this.pruneTransient()
@ -716,6 +740,7 @@ class TurnController {
this.reasoningSegmentIndex = null
this.turnTools = []
this.toolTokenAcc = 0
this.interrupted = false
this.persistedToolLabels.clear()
patchUiState({ busy: true })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })