mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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).
49 lines
1.7 KiB
TypeScript
49 lines
1.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { formatAbandonedClarify, stripTrailingPasteNewlines } from './text.js'
|
|
|
|
describe('stripTrailingPasteNewlines', () => {
|
|
it('removes trailing newline runs from pasted text', () => {
|
|
expect(stripTrailingPasteNewlines('alpha\n')).toBe('alpha')
|
|
expect(stripTrailingPasteNewlines('alpha\nbeta\n\n')).toBe('alpha\nbeta')
|
|
})
|
|
|
|
it('preserves interior newlines', () => {
|
|
expect(stripTrailingPasteNewlines('alpha\nbeta\ngamma')).toBe('alpha\nbeta\ngamma')
|
|
})
|
|
|
|
it('preserves newline-only pastes', () => {
|
|
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.')
|
|
})
|
|
})
|