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.
This commit is contained in:
Brooklyn Nicholson
2026-04-27 15:18:37 -05:00
parent 4a9ac5c355
commit 1458c785ec
2 changed files with 61 additions and 6 deletions

View File

@@ -474,4 +474,42 @@ describe('createGatewayEventHandler', () => {
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
})
it('drops stale reasoning/tool events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for
// ~1s, making it look like the interrupt had been ignored.
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { context: 'pre', name: 'search', tool_id: 't-1' }, type: 'tool.start' } as any)
turnController.interruptTurn({
appendMessage: (msg: Msg) => appended.push(msg),
gw: ctx.gateway.gw,
sid: 'sess-1',
sys: ctx.system.sys
})
onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
onEvent({ payload: { context: 'post', name: 'browser', tool_id: 't-2' }, type: 'tool.start' } as any)
onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any)
onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)
expect(getTurnState().tools).toEqual([])
expect(turnController.reasoningText).toBe('')
expect(turnController.bufRef).toBe('')
expect(getTurnState().streamPendingTools).toEqual([])
expect(getTurnState().streamSegments).toEqual([])
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)
expect(turnController.reasoningText).toBe('fresh')
})
})

View File

@@ -509,13 +509,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 +524,7 @@ class TurnController {
}
recordReasoningAvailable(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}
@@ -542,7 +542,7 @@ class TurnController {
}
recordReasoningDelta(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}
@@ -570,6 +570,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 +589,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 +634,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 +657,10 @@ class TurnController {
}
recordToolStart(toolId: string, name: string, context: string) {
if (this.interrupted) {
return
}
this.flushStreamingSegment()
this.closeReasoningSegment()
this.pruneTransient()
@@ -716,6 +732,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: [] })