diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c09bd4ee966..2ba612dbc39 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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') + }) }) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 49a7fd7d67e..d67292e40bb 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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: [] })