mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(approvals): clear the red tab alert when a dialog is answered in the terminal
Confirming an AskUserQuestion left its tab flowing red for the rest of the turn (owner report: ~8 minutes on a running session, with no dialog anywhere on screen). Two separate bugs, both live-verified. The re-capture erased the evidence the staleness check runs on. Claude Code fires the Notification behind the dialog (measured 6-7s on v2.1.237, documented up to ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered, parses nothing, and applyCapture overwrote item.options with undefined. A MISSING options is how "we never could read this dialog" is expressed, and those items stay answerable by design, so a cleared field was indistinguishable from a never-parsed one and the item became permanently unsweepable: it survived every GET /api/approvals and every page reload, cleared only on `stop`, and still accepted an answer, sending a bare `1` into a composer with no dialog under it. applyCapture is now ADD-ONLY for options. Nothing ran the staleness check while a page was open. It lived only in GET /api/approvals, which seedApprovals() calls on init and reconnect, so `stop` was the first thing that ever cleared an answered dialog. The `working` signal now runs the pane-VERIFIED variant (resolveIfDialogGone -> verifyStillAnswerable): the heuristic only decides when to look, the screen decides the outcome, so the existing "working can flap" rule is respected. A frame that parses no options is now conclusive in two cases, and only those, so an unreadable capture still keeps the alert: the item once parsed options, or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist - measured, a live-dialog frame carries neither the elapsed-timer spinner nor the "esc to interrupt" footer, which the dialog replaces with "Enter to select". That second signal is reached by a delayed staleness pass (3s) scheduled alongside the re-capture, which closes the late-hook case where the prompt is answered before the hook lands: nothing ever parses, `stop` may have gone by already, and the alert outlived reloads until the 12h TTL. The pass is deliberately later than RECAPTURE_DELAY_MS, whose whole reason for existing is that the hook can beat Ink to the screen. Frontend: _onHookElicitationComplete cleared only the elicitation entry, but an AskUserQuestion arrives as permission_prompt, so it was clearing the wrong alert; it now clears both, matching the server's kind-agnostic APPROVAL_RESOLVING_EVENTS. Verified end to end on an isolated beta instance, not just in unit tests: before, resolution could only come from the stop route (approval:resolved always immediately preceding hook:stop); after, it arrives from the new paths, and a simulated late hook resolves at +3.12s with no stop, no working signal and no GET, while the pane is still working. Tests use frames captured off a live pane and each new one was confirmed to fail against the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,39 @@ const ASK_USER_QUESTION_FRAME = [
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
// Both frames captured off a live Claude Code v2.1.237 pane. They are the
|
||||
// discriminator behind the late-hook resolution: a modal dialog BLOCKS the
|
||||
// turn, so a working line and a dialog cannot coexist. Note the dialog frame
|
||||
// carries no `esc to interrupt` footer either — the dialog replaces it.
|
||||
const LIVE_DIALOG_FRAME = [
|
||||
'● Bash(sleep 12; echo "slept 12s")',
|
||||
' ⎿ slept 12s',
|
||||
'────────────────────────────────────────',
|
||||
' ☐ Proceed',
|
||||
'',
|
||||
'Proceed?',
|
||||
'',
|
||||
'❯ 1. Yes',
|
||||
' Go ahead and proceed.',
|
||||
' 2. No',
|
||||
' Do not proceed.',
|
||||
' 3. Type something.',
|
||||
'────────────────────────────────────────',
|
||||
' 4. Chat about this',
|
||||
'',
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
const WORKING_FRAME = [
|
||||
'● Bash(sleep 25)',
|
||||
' ⎿ Tip: Use git worktrees to run multiple Claude sessions in parallel.',
|
||||
'✢ Clauding… (13s · ↓ 1.4k tokens)',
|
||||
'────────────────────────────────────────',
|
||||
'❯',
|
||||
'────────────────────────────────────────',
|
||||
' ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents',
|
||||
].join('\n');
|
||||
|
||||
function collect(inbox: ApprovalInbox) {
|
||||
const pending: ApprovalItem[] = [];
|
||||
const updated: ApprovalItem[] = [];
|
||||
@@ -333,6 +366,186 @@ describe('ApprovalInbox', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('answered-in-the-terminal staleness', () => {
|
||||
// Claude Code delays the Notification hook behind the dialog (measured 6s
|
||||
// on v2.1.237, documented up to ~30s), so the 600ms re-capture routinely
|
||||
// lands on a frame the user has ALREADY answered. Erasing `options` there
|
||||
// made the item permanently unsweepable, because a missing `options` is how
|
||||
// "we never could read this dialog" is expressed, and such items stay
|
||||
// answerable on purpose. The red tab alert then survived every
|
||||
// `GET /api/approvals` and every reload, clearing only on `stop`.
|
||||
it('a re-capture taken after the answer does not erase parsed options', () => {
|
||||
const { updated } = collect(inbox);
|
||||
let frame = ASK_USER_QUESTION_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
expect(item.options).toHaveLength(5);
|
||||
|
||||
// Answered in the terminal before the re-capture fires.
|
||||
frame = "● User answered Claude's questions:\n ⎿ · Which color do you prefer? → Red\n\n✶ Cooking… (6s)";
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(inbox.getById(item.id)?.context).toContain('User answered');
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(5);
|
||||
// ...which keeps the staleness check conclusive instead of inconclusive.
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone resolves a dialog answered in the terminal', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
let frame = PERMISSION_FRAME;
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
frame = 'the dialog is gone, claude is typing';
|
||||
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone keeps a dialog that is still on screen', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone never touches an idle item (that is the working signal job)', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'idle',
|
||||
capture: () => 'composer is empty',
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
// The late-hook hole: Claude Code fires the Notification behind the dialog,
|
||||
// so a prompt answered before the hook lands produces an item whose FIRST
|
||||
// capture already has no dialog in it. Nothing ever parsed, so "options
|
||||
// vanished" can never fire, and `stop` had already gone by too — the red
|
||||
// alert then survived reloads until the 12h TTL.
|
||||
it('resolves an item that never parsed options once the pane is visibly working', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('keeps an unreadable dialog answerable when the pane is NOT visibly working', () => {
|
||||
// The conservative rule this fix must not loosen: no options and no proof
|
||||
// the turn is running means "we cannot read it", not "it is gone".
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => 'some dialog shape we cannot parse',
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('a real live-dialog frame carries no working line, so it is never false-resolved', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => LIVE_DIALOG_FRAME,
|
||||
});
|
||||
expect(item.options).toHaveLength(4);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone clears a late-hook item on the next working signal', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the delayed staleness pass resolves a late-hook item with no working signal needed', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
vi.advanceTimersByTime(600); // re-capture: enrichment only, never resolves
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(2400); // the delayed staleness pass
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('a dialog Ink paints late is NOT resolved by the delayed pass', () => {
|
||||
// The paint race the re-capture exists for: the hook can beat Ink to the
|
||||
// screen. Resolving inside that window would clear the alert for a dialog
|
||||
// that was about to appear, so the frame is what decides, every time.
|
||||
let frame = WORKING_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
frame = LIVE_DIALOG_FRAME; // Ink finishes painting
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(4);
|
||||
vi.advanceTimersByTime(2400);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolving cancels both pending timers', () => {
|
||||
const { updated } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.dismiss(item.id);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(updated).toHaveLength(0);
|
||||
expect(inbox.listPending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone is a no-op for a session with nothing pending', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
expect(() => inbox.resolveIfDialogGone('nobody')).not.toThrow();
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('stop() clears items and silences events', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
|
||||
Reference in New Issue
Block a user