Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
})
37 changes: 31 additions & 6 deletions ui-tui/src/app/turnController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ class TurnController {
}

recordTodos(value: unknown) {
if (this.interrupted) {
return
}

const todos = parseTodos(value)

if (todos !== null) {
Expand Down Expand Up @@ -397,6 +401,10 @@ class TurnController {
}

pushTrail(line: string) {
if (this.interrupted) {
return
}

patchTurnState(state => {
if (state.turnTrail.at(-1) === line) {
return state
Expand Down Expand Up @@ -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) {
Expand All @@ -524,7 +532,7 @@ class TurnController {
}

recordReasoningAvailable(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}

Expand All @@ -542,7 +550,7 @@ class TurnController {
}

recordReasoningDelta(text: string) {
if (!getUiState().showReasoning) {
if (this.interrupted || !getUiState().showReasoning) {
return
}

Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -645,6 +665,10 @@ class TurnController {
}

recordToolStart(toolId: string, name: string, context: string) {
if (this.interrupted) {
return
}

Comment thread
OutThisLife marked this conversation as resolved.
this.flushStreamingSegment()
this.closeReasoningSegment()
this.pruneTransient()
Comment thread
OutThisLife marked this conversation as resolved.
Expand Down Expand Up @@ -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: [] })
Expand Down
Loading