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:
@@ -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: …"
|
||||
|
||||
@@ -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 ?? {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user