fix(tui): persist timed-out/cancelled clarify prompts in transcript

When a clarify prompt times out (backend _block returns an empty answer
after the configured timeout) or is dismissed with Esc/Ctrl+C, the live
ClarifyPrompt overlay was torn down by turnController.idle() ->
resetFlowOverlays() with no persistent transcript record. The question and
options vanished from the screen while the agent's follow-up still referred
to "the options above".

The answered path already persists the question + answer; only the
unanswered exits left no trace. This asymmetry is the bug.

Fix (TUI layer only, no Python/protocol change):
- formatAbandonedClarify() in lib/text.ts renders the question + the same
  1-based numbered option list shown by ClarifyPrompt, plus a reason
  ('timed out' / 'cancelled').
- Timeout: createGatewayEventHandler flushes a still-live clarify into the
  transcript as a plain system line when the clarify tool's own tool.complete
  fires. A live capture of the event stream confirmed this is the only point
  where the overlay is still set after a timeout: the sequence is
  clarify.request -> (timeout) -> tool.complete -> message.complete, with NO
  intervening message.start/tool.start. On a real answer, answerClarify()
  clears the overlay before tool.complete arrives, so the hook no-ops there
  (no double-write); a per-requestId guard set is belt-and-braces.
- Explicit cancel: answerClarify('') persists the prompt as a system line
  instead of a transient 'prompt cancelled' flash.

System lines always render (unlike trail lines, which /details can hide),
so the record reliably survives on screen as standard output.

Verified live in the TUI: an Esc-cancelled clarify now leaves the question +
options + '(cancelled - no selection)' in the transcript after the turn ends.

Tests: formatAbandonedClarify unit cases + gateway-handler behavioral cases
(persist on clarify tool.complete, no flush on a non-clarify tool.complete,
no double-persist on repeat tool.complete, no-op when the overlay was already
cleared by an answer).
This commit is contained in:
Ben
2026-06-04 11:00:31 +10:00
committed by Teknium
parent dfd6bcf1ff
commit 825629424d
5 changed files with 159 additions and 5 deletions

View File

@ -1113,4 +1113,68 @@ describe('createGatewayEventHandler', () => {
vi.useRealTimers()
}
})
it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Backend clarify timed out: the overlay is still live (Python returned an
// empty answer), and the clarify tool's own tool.complete then fires.
patchOverlayState({
clarify: { choices: ['Scope A', 'Scope B'], question: 'How do you want to scope?', requestId: 'req-1' }
})
onEvent({ payload: { duration_s: 300, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask How do you want to scope?'))
expect(record).toBeDefined()
expect(record?.text).toContain('1. Scope A')
expect(record?.text).toContain('2. Scope B')
expect(record?.text).toContain('timed out — no selection')
// The live overlay is cleared so it doesn't double-render with the record.
expect(getOverlayState().clarify).toBeNull()
})
it('only persists an abandoned clarify once even if tool.complete fires twice', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
patchOverlayState({
clarify: { choices: ['A'], question: 'Pick?', requestId: 'req-3' }
})
onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
// A duplicate clarify tool.complete must not re-persist the same prompt.
onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
const records = appended.filter(msg => msg.role === 'system' && msg.text.startsWith('ask Pick?'))
expect(records).toHaveLength(1)
})
it('does not flush the clarify overlay when a non-clarify tool completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// A clarify is live, but it's a *different* tool that just completed — the
// clarify itself is still pending, so we must not persist or clear it.
patchOverlayState({
clarify: { choices: ['A', 'B'], question: 'Pick?', requestId: 'req-4' }
})
onEvent({ payload: { name: 'search', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
expect(getOverlayState().clarify).not.toBeNull()
})
it('does not persist when an answered clarify already cleared the overlay before tool.complete', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Answered path (answerClarify) clears the overlay before the agent's
// tool.complete arrives, so there's nothing live to persist.
onEvent({ payload: { duration_s: 4.2, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any)
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
})
})

View File

@ -11,7 +11,7 @@ import type {
} from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatToolCall, stripAnsi } from '../lib/text.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
@ -87,6 +87,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
let startupPromptSubmitted = false
// Request IDs of clarify prompts we've already flushed to the transcript as
// an abandoned-prompt record, so the tool.complete and message.complete
// paths can't both persist the same prompt twice.
const persistedAbandonedClarify = new Set<string>()
// When a clarify prompt is dismissed without an answer (the backend _block
// timed out and returned an empty string), the live ClarifyPrompt overlay is
// left set until the next turn's idle() silently nulls it — so the question
// and options vanish from the screen while the agent's follow-up still refers
// to them. The reliable signal is the clarify tool's own tool.complete (and,
// as a backstop, message.complete): at those points the overlay is provably
// still set on a timeout, but already cleared by answerClarify() on a real
// answer (so this no-ops there). Flush the question + options into the
// transcript as a persistent system line, then clear the overlay.
const flushAbandonedClarify = () => {
const { clarify } = getOverlayState()
if (!clarify || persistedAbandonedClarify.has(clarify.requestId)) {
return
}
persistedAbandonedClarify.add(clarify.requestId)
appendMessage({
role: 'system',
text: formatAbandonedClarify(clarify.question, clarify.choices, 'timed out')
})
patchOverlayState({ clarify: null })
}
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
turnController.persistSpawnTree = async (subagents, sessionId) => {
@ -624,6 +653,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'tool.complete': {
// The clarify tool finishing with its overlay still live means it was
// abandoned (backend _block timed out, empty answer). A real answer
// clears the overlay in answerClarify() before this fires, so this
// no-ops there. Persist the question + options so they don't vanish.
if (ev.payload.name === 'clarify') {
flushAbandonedClarify()
}
const inlineDiffText =
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''

View File

@ -25,7 +25,7 @@ import { appendTranscriptMessage } from '../lib/messages.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
@ -608,13 +608,19 @@ export function useMainApp(gw: GatewayClient) {
appendMessage({ role: 'user', text: answer })
patchUiState({ status: 'running…' })
} else {
sys('prompt cancelled')
// Esc / Ctrl+C cancel: persist the question + options as a system
// line (not a transient "prompt cancelled" flash) so the prompt
// survives on screen as standard output, matching the timeout path.
appendMessage({
role: 'system',
text: formatAbandonedClarify(clarify.question, clarify.choices, 'cancelled')
})
}
patchOverlayState({ clarify: null })
})
},
[appendMessage, overlay.clarify, rpc, sys]
[appendMessage, overlay.clarify, rpc]
)
const paste = useCallback(

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { stripTrailingPasteNewlines } from './text.js'
import { formatAbandonedClarify, stripTrailingPasteNewlines } from './text.js'
describe('stripTrailingPasteNewlines', () => {
it('removes trailing newline runs from pasted text', () => {
@ -16,3 +16,34 @@ describe('stripTrailingPasteNewlines', () => {
expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n')
})
})
describe('formatAbandonedClarify', () => {
it('renders the question, numbered options, and reason', () => {
const out = formatAbandonedClarify('How do you want to scope?', ['Option A', 'Option B', 'Option C'], 'timed out')
expect(out).toBe(
['ask How do you want to scope?', ' 1. Option A', ' 2. Option B', ' 3. Option C', ' (timed out — no selection)'].join(
'\n'
)
)
})
it('handles a prompt with no choices (free-text clarify)', () => {
const out = formatAbandonedClarify('What is the target branch?', null, 'cancelled')
expect(out).toBe(['ask What is the target branch?', ' (cancelled — no selection)'].join('\n'))
})
it('trims surrounding whitespace on the question', () => {
const out = formatAbandonedClarify(' trailing space ', [], 'timed out')
expect(out.split('\n')[0]).toBe('ask trailing space')
})
it('numbers options 1-based to match the live ClarifyPrompt', () => {
const out = formatAbandonedClarify('q', ['first'], 'timed out')
expect(out).toContain(' 1. first')
expect(out).not.toContain(' 0.')
})
})

View File

@ -338,6 +338,22 @@ export const estimateRows = (text: string, w: number, compact = false) => {
return Math.max(1, rows)
}
/**
* Render an unanswered clarify prompt (timed out, or cancelled with Esc/Ctrl+C)
* as a persistent transcript block. The live `ClarifyPrompt` overlay is torn
* down the moment the turn settles, so without this the question + options
* vanish from the screen while the agent's follow-up still refers to "the
* options above". Mirrors the option formatting in ClarifyPrompt (the same
* 1-based numbered list) so the persisted record reads identically to what was
* on screen. `reason` states why the prompt ended ("timed out", "cancelled").
*/
export const formatAbandonedClarify = (question: string, choices: string[] | null, reason: string) => {
const head = `ask ${question.trim()}`
const opts = (choices ?? []).map((c, i) => ` ${i + 1}. ${c}`)
return [head, ...opts, ` (${reason} — no selection)`].join('\n')
}
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })