fix(tui): Ctrl+C shows the same partial reply the session saves

interruptTurn seals bufRef at the keypress and drops later message.delta
frames, but the agent keeps streaming until it notices the interrupt and
persists every delta it delivered. Deltas already in flight were saved to
state.db (and replayed next turn) without ever being drawn.

The interrupted message.complete carries that persisted partial; when it
extends the sealed text, the interrupted bubble now takes it (or a bubble
is added when nothing had been shown yet).
This commit is contained in:
teknium1
2026-09-24 06:29:39 -07:00
committed by Teknium
parent 02f98f21f8
commit 2d38c33da3
3 changed files with 100 additions and 7 deletions

View File

@@ -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: …"

View File

@@ -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 ?? {}

View File

@@ -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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
@@ -142,6 +170,7 @@ const clear = (t: Timer): null => {
class TurnController {
bufRef = ''
interrupted = false
sealedInterrupt: null | SealedInterrupt = null
lastStatusNote = ''
persistedToolLabels = new Set<string>()
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
@@ -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