diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fbcc069dd5..d9f147c7a5 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -495,4 +495,87 @@ describe('createGatewayEventHandler', () => { expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }]) }) + + it('drops stale reasoning/tool/todos 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. + // + // Fake timers because `interruptTurn` schedules a real setTimeout for + // its cooldown — without flushing it inside this test, the timeout + // can fire later and mutate uiStore/turnState during unrelated tests + // (cross-file flake). + vi.useFakeTimers() + + try { + 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', + todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }], + tool_id: 't-1' + }, + type: 'tool.start' + } as any) + + // Pre-interrupt todos should land in turn state. + expect(getTurnState().todos).toEqual([ + { content: 'pre-interrupt', id: 'todo-1', status: 'pending' } + ]) + + 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) + // Post-interrupt tool.start with a todos payload — must NOT mutate todos. + onEvent({ + payload: { + context: 'post', + name: 'browser', + todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }], + tool_id: 't-2' + }, + type: 'tool.start' + } as any) + // Late tool.generating must NOT push a 'drafting …' line into the trail. + const trailBefore = getTurnState().turnTrail.length + onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any) + expect(getTurnState().turnTrail.length).toBe(trailBefore) + 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([]) + // Stale post-interrupt todos must not have leaked through. + // (This test does not assert that pre-interrupt todos are cleared — + // current interrupt path leaves them visible until the next message.) + expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined() + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any) + + expect(turnController.reasoningText).toBe('fresh') + } finally { + // Drain pending fake timers BEFORE restoring real timers so a mid- + // test assertion failure can't leak the interrupt-cooldown setTimeout + // across test files (the original Copilot concern). + vi.runAllTimers() + vi.useRealTimers() + } + }) }) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 49a7fd7d67..dbd5e1faf0 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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: [] })