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', () => { 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 // 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: …" // 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': { 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) { if (!wasInterrupted) {
const payload = ev.payload ?? {} const payload = ev.payload ?? {}

View File

@@ -122,6 +122,34 @@ const finalTail = (finalText: string, segments: Msg[]) => {
return tail 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 { export interface InterruptDeps {
appendMessage: (msg: Msg) => void appendMessage: (msg: Msg) => void
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> } gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
@@ -142,6 +170,7 @@ const clear = (t: Timer): null => {
class TurnController { class TurnController {
bufRef = '' bufRef = ''
interrupted = false interrupted = false
sealedInterrupt: null | SealedInterrupt = null
lastStatusNote = '' lastStatusNote = ''
persistedToolLabels = new Set<string>() persistedToolLabels = new Set<string>()
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void> 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 // otherwise emit a sys note so the transcript always records that the
// turn was cancelled, even when only prior `segments` were preserved. // turn was cancelled, even when only prior `segments` were preserved.
if (partial || tools.length) { if (partial || tools.length) {
appendMessage({ const text = interruptedText(partial)
role: 'assistant',
text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*', appendMessage({ role: 'assistant', text, ...(tools.length && { tools }) })
...(tools.length && { tools }) this.sealedInterrupt = { partial, text }
})
} else { } else {
sys('interrupted') sys('interrupted')
this.sealedInterrupt = { partial: '', text: null }
} }
this.clearStatusTimer() this.clearStatusTimer()
@@ -687,6 +716,7 @@ class TurnController {
} }
const wasInterrupted = this.interrupted const wasInterrupted = this.interrupted
const interruptedReply = wasInterrupted ? lateInterruptedReply(this.sealedInterrupt, payload) : null
// Archive the turn's spawn tree to history BEFORE idle() drops subagents // Archive the turn's spawn tree to history BEFORE idle() drops subagents
// from turnState. Lets /replay and the overlay's history nav pull up // from turnState. Lets /replay and the overlay's history nav pull up
@@ -708,13 +738,14 @@ class TurnController {
this.persistedToolLabels.clear() this.persistedToolLabels.clear()
this.bufRef = '' this.bufRef = ''
this.interrupted = false this.interrupted = false
this.sealedInterrupt = null
patchTurnState({ activity: [], outcome: '' }) patchTurnState({ activity: [], outcome: '' })
// Real turn end: surface any notice held back while busy. Done after // Real turn end: surface any notice held back while busy. Done after
// idle() flips busy=false so applyNotice() reaches the visible slot. // idle() flips busy=false so applyNotice() reaches the visible slot.
this.flushPendingNotice() this.flushPendingNotice()
return { finalMessages, finalText, wasInterrupted } return { finalMessages, finalText, interruptedReply, wasInterrupted }
} }
recordMessageDelta({ text }: { rendered?: string | null; text?: string }) { recordMessageDelta({ text }: { rendered?: string | null; text?: string }) {
@@ -937,6 +968,7 @@ class TurnController {
this.idle() this.idle()
this.bufRef = '' this.bufRef = ''
this.interrupted = false this.interrupted = false
this.sealedInterrupt = null
this.lastStatusNote = '' this.lastStatusNote = ''
this.activeReasoningText = '' this.activeReasoningText = ''
this.pendingSegmentTools = [] this.pendingSegmentTools = []
@@ -1003,6 +1035,7 @@ class TurnController {
this.turnTools = [] this.turnTools = []
this.toolTokenAcc = 0 this.toolTokenAcc = 0
this.interrupted = false this.interrupted = false
this.sealedInterrupt = null
this.persistedToolLabels.clear() this.persistedToolLabels.clear()
// "Flash and yield" notices clear when a new turn starts: a usage-band heads-up // "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 // (credits.usage, 50/75/90%) and the one-time "grant spent" transition