diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 768123b5c2..0052c10898 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1622,6 +1622,49 @@ describe('createGatewayEventHandler', () => { } }) + // Ctrl+C seals the reply at the keypress, but the agent streams until it + // notices the interrupt and persists everything it streamed (state.db and the + // next request's history). The screen must show that same partial. + const interruptedTranscript = (deltas: string[], late: string[], persisted: string) => { + vi.useFakeTimers() + + try { + const history: Msg[] = [] + const ctx = buildCtx(history) + ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' })) + ctx.transcript.setHistoryItems = (next: ((prev: Msg[]) => Msg[]) | Msg[]) => + history.splice(0, history.length, ...(typeof next === 'function' ? next([...history]) : next)) + const onEvent = createGatewayEventHandler(ctx) + + patchUiState({ sid: 'sess-1' }) + onEvent({ payload: {}, type: 'message.start' } as any) + deltas.forEach(text => onEvent({ payload: { text }, type: 'message.delta' } as any)) + turnController.interruptTurn({ + appendMessage: (msg: Msg) => history.push(msg), + gw: ctx.gateway.gw, + sid: 'sess-1', + sys: ctx.system.sys + }) + late.forEach(text => onEvent({ payload: { text }, type: 'message.delta' } as any)) + onEvent({ payload: { status: 'interrupted', text: persisted }, type: 'message.complete' } as any) + + return history.filter(m => m.role === 'assistant').map(m => m.text) + } finally { + vi.runAllTimers() + vi.useRealTimers() + } + } + + it('an interrupted reply shows the partial the agent persisted, including deltas streamed after Ctrl+C', () => { + expect(interruptedTranscript(['alpha beta', ' ga'], ['mma', ' delta'], 'alpha beta gamma delta')).toEqual([ + 'alpha beta gamma delta\n\n*[interrupted]*' + ]) + }) + + it('an interrupted reply whose every delta landed after Ctrl+C still shows the persisted partial', () => { + expect(interruptedTranscript([], ['alpha', ' beta'], 'alpha beta')).toEqual(['alpha beta\n\n*[interrupted]*']) + }) + it('keepBusy interrupt holds busy until the gateway settles and suppresses the cancelled turn’s final_response', () => { // Force-send: interrupt holds busy so the drain waits for the real settle // instead of racing it (the race duplicated the bubble, leaked a "queued: …" diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 348168989e..afb54e8066 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1532,7 +1532,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'message.complete': { - const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) + const { finalMessages, finalText, interruptedReply, wasInterrupted } = turnController.recordMessageComplete( + ev.payload ?? {} + ) + + // Ctrl+C sealed the reply before the agent stopped streaming: take the + // persisted partial so the screen shows what state.db (and the next + // request) holds. + if (interruptedReply?.from === null) { + appendMessage({ role: 'assistant', text: interruptedReply.to }) + } else if (interruptedReply) { + const { from, to } = interruptedReply + + setHistoryItems(prev => { + const at = prev.findLastIndex(m => m.role === 'assistant' && m.text === from) + + return at < 0 ? prev : prev.map((m, i) => (i === at ? { ...m, text: to } : m)) + }) + } if (!wasInterrupted) { const payload = ev.payload ?? {} diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 752e52460f..e78475d141 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -122,6 +122,34 @@ const finalTail = (finalText: string, segments: Msg[]) => { return tail } +const interruptedText = (partial: string) => (partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*') + +// What interruptTurn sealed into the transcript: `text` is the bubble it +// appended (null when it only wrote a sys note), `partial` the reply text in it. +export interface SealedInterrupt { + partial: string + text: null | string +} + +// The bubble fix for an honoured interrupt: Ctrl+C seals the reply the moment +// the key lands, but the agent keeps streaming until it notices the interrupt, +// and it persists (and replays next turn) everything it streamed. The +// interrupted message.complete carries that persisted partial; when it extends +// what was sealed, the transcript takes it so the screen matches state.db. +export const lateInterruptedReply = ( + sealed: null | SealedInterrupt, + payload: MessageCompletePayload +): null | { from: null | string; to: string } => { + const persisted = typeof payload.text === 'string' ? payload.text.trim() : '' + const shown = sealed?.partial.trimEnd() ?? '' + + if (!sealed || payload.status !== 'interrupted' || persisted.length <= shown.length || !persisted.startsWith(shown)) { + return null + } + + return { from: sealed.text, to: interruptedText(persisted) } +} + export interface InterruptDeps { appendMessage: (msg: Msg) => void gw: { request: (method: string, params?: Record) => Promise } @@ -142,6 +170,7 @@ const clear = (t: Timer): null => { class TurnController { bufRef = '' interrupted = false + sealedInterrupt: null | SealedInterrupt = null lastStatusNote = '' persistedToolLabels = new Set() persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise @@ -358,13 +387,13 @@ class TurnController { // otherwise emit a sys note so the transcript always records that the // turn was cancelled, even when only prior `segments` were preserved. if (partial || tools.length) { - appendMessage({ - role: 'assistant', - text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*', - ...(tools.length && { tools }) - }) + const text = interruptedText(partial) + + appendMessage({ role: 'assistant', text, ...(tools.length && { tools }) }) + this.sealedInterrupt = { partial, text } } else { sys('interrupted') + this.sealedInterrupt = { partial: '', text: null } } this.clearStatusTimer() @@ -687,6 +716,7 @@ class TurnController { } const wasInterrupted = this.interrupted + const interruptedReply = wasInterrupted ? lateInterruptedReply(this.sealedInterrupt, payload) : null // Archive the turn's spawn tree to history BEFORE idle() drops subagents // from turnState. Lets /replay and the overlay's history nav pull up @@ -708,13 +738,14 @@ class TurnController { this.persistedToolLabels.clear() this.bufRef = '' this.interrupted = false + this.sealedInterrupt = null patchTurnState({ activity: [], outcome: '' }) // Real turn end: surface any notice held back while busy. Done after // idle() flips busy=false so applyNotice() reaches the visible slot. this.flushPendingNotice() - return { finalMessages, finalText, wasInterrupted } + return { finalMessages, finalText, interruptedReply, wasInterrupted } } recordMessageDelta({ text }: { rendered?: string | null; text?: string }) { @@ -937,6 +968,7 @@ class TurnController { this.idle() this.bufRef = '' this.interrupted = false + this.sealedInterrupt = null this.lastStatusNote = '' this.activeReasoningText = '' this.pendingSegmentTools = [] @@ -1003,6 +1035,7 @@ class TurnController { this.turnTools = [] this.toolTokenAcc = 0 this.interrupted = false + this.sealedInterrupt = null this.persistedToolLabels.clear() // "Flash and yield" notices clear when a new turn starts: a usage-band heads-up // (credits.usage, 50/75/90%) and the one-time "grant spent" transition