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:
+110
-26
@@ -22,14 +22,14 @@
|
|||||||
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
||||||
* item stays pending, it just stops arming the tab alert on every client.
|
* item stays pending, it just stops arming the tab alert on every client.
|
||||||
*
|
*
|
||||||
* @dependencies utils (stripAnsi)
|
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||||
*
|
*
|
||||||
* @module web/approval-inbox
|
* @module web/approval-inbox
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stripAnsi } from '../utils/index.js';
|
import { stripAnsi, CLAUDE_WORKING_LINE_PATTERN } from '../utils/index.js';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,6 +106,12 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
|
|||||||
* single delayed re-capture picks up the frame the immediate capture missed.
|
* single delayed re-capture picks up the frame the immediate capture missed.
|
||||||
*/
|
*/
|
||||||
const RECAPTURE_DELAY_MS = 600;
|
const RECAPTURE_DELAY_MS = 600;
|
||||||
|
/**
|
||||||
|
* Delayed staleness pass for the late-hook case (see notePrompt). Comfortably
|
||||||
|
* clear of RECAPTURE_DELAY_MS so a dialog Ink has not painted yet is never
|
||||||
|
* mistaken for one that is gone.
|
||||||
|
*/
|
||||||
|
const STALE_CHECK_DELAY_MS = 3000;
|
||||||
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
||||||
const MAX_CONTEXT_CHARS = 4000;
|
const MAX_CONTEXT_CHARS = 4000;
|
||||||
const MAX_CONTEXT_LINES = 30;
|
const MAX_CONTEXT_LINES = 30;
|
||||||
@@ -195,7 +201,8 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
|||||||
export class ApprovalInbox {
|
export class ApprovalInbox {
|
||||||
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
||||||
private items = new Map<string, ApprovalItem>();
|
private items = new Map<string, ApprovalItem>();
|
||||||
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
/** Post-capture timers per item id (re-capture + the delayed staleness check). */
|
||||||
|
private itemTimers = new Map<string, ReturnType<typeof setTimeout>[]>();
|
||||||
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||||
private captures = new Map<string, () => string | null>();
|
private captures = new Map<string, () => string | null>();
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
@@ -229,31 +236,55 @@ export class ApprovalInbox {
|
|||||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||||
this.onPending?.(item);
|
this.onPending?.(item);
|
||||||
if (args.capture && !this.stopped) {
|
if (args.capture && !this.stopped) {
|
||||||
const timer = setTimeout(() => {
|
// Pass 1 (600ms): enrich the card with the painted frame.
|
||||||
this.recaptureTimers.delete(item.id);
|
this.scheduleForItem(item, RECAPTURE_DELAY_MS, () => {
|
||||||
// Only update the item if it is still the live one for the session.
|
|
||||||
if (this.items.get(args.sessionId)?.id !== item.id) return;
|
|
||||||
this.applyCapture(item, args.capture);
|
this.applyCapture(item, args.capture);
|
||||||
this.onUpdated?.(item);
|
this.onUpdated?.(item);
|
||||||
}, RECAPTURE_DELAY_MS);
|
});
|
||||||
this.recaptureTimers.set(item.id, timer);
|
// Pass 2: the late-hook staleness check. Claude Code fires the
|
||||||
|
// Notification behind the dialog, so a prompt answered before the hook
|
||||||
|
// lands creates an item for a dialog that is ALREADY gone: nothing ever
|
||||||
|
// parsed, so the "options vanished" test can never fire, `stop` may have
|
||||||
|
// gone by already, and the red alert then outlived reloads until the 12h
|
||||||
|
// TTL. This pass re-reads the pane and resolves when the frame proves no
|
||||||
|
// dialog is up. Deliberately LATER than the re-capture, whose whole
|
||||||
|
// reason for existing is that Ink may not have painted the dialog yet:
|
||||||
|
// resolving inside that window could clear the alert for a dialog that
|
||||||
|
// was about to appear.
|
||||||
|
this.scheduleForItem(item, STALE_CHECK_DELAY_MS, () => {
|
||||||
|
this.verifyStillAnswerable(item.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answer-time guard: re-capture the pane and check the dialog is still on
|
* Answer-time guard: re-capture the pane and check the dialog is still on
|
||||||
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
|
* screen before keystrokes are sent at it. If the dialog is gone (answered in
|
||||||
* frame parsed options: if a fresh capture then parses none, the dialog is
|
* the terminal moments ago) the item resolves and the answer is refused,
|
||||||
* gone (answered in the terminal moments ago), so the item resolves and the
|
* because the digit would land in whatever now has focus.
|
||||||
* answer must be refused, because the digit would land in whatever now has
|
*
|
||||||
* focus. Unparseable-from-the-start items stay answerable (approve/deny
|
* A fresh frame that parses NO options is conclusive in two cases, and only
|
||||||
* only), same risk the terminal user already carries.
|
* those; anything else stays answerable, so an unreadable capture keeps the
|
||||||
|
* alert rather than losing a live dialog:
|
||||||
|
*
|
||||||
|
* 1. The item HAD parsed options. They cannot vanish while the dialog is up.
|
||||||
|
* 2. The frame shows Claude actively running a turn. A modal dialog BLOCKS
|
||||||
|
* the turn, so a working line and a dialog cannot coexist — measured on
|
||||||
|
* v2.1.237: a live-dialog frame carries neither the `… (13s` timer nor
|
||||||
|
* even the `esc to interrupt` footer, which the dialog replaces with
|
||||||
|
* `Enter to select · ↑/↓ to navigate · Esc to cancel`.
|
||||||
|
*
|
||||||
|
* Case 2 is what closes 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 — never
|
||||||
|
* parsed, so case 1 can never fire, and the red alert then outlived even
|
||||||
|
* `stop` (which had already fired) and survived reloads until the 12h TTL.
|
||||||
*/
|
*/
|
||||||
verifyStillAnswerable(id: string): boolean {
|
verifyStillAnswerable(id: string): boolean {
|
||||||
const item = this.getById(id);
|
const item = this.getById(id);
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
if (item.kind === 'idle' || !item.options) return true;
|
if (item.kind === 'idle') return true;
|
||||||
const capture = this.captures.get(item.sessionId);
|
const capture = this.captures.get(item.sessionId);
|
||||||
if (!capture) return true;
|
if (!capture) return true;
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
@@ -266,14 +297,39 @@ export class ApprovalInbox {
|
|||||||
if (!context) return true;
|
if (!context) return true;
|
||||||
const options = parseDialogOptions(context);
|
const options = parseDialogOptions(context);
|
||||||
if (!options) {
|
if (!options) {
|
||||||
this.remove(item, 'resolved_in_terminal');
|
if (item.options || CLAUDE_WORKING_LINE_PATTERN.test(context)) {
|
||||||
return false;
|
this.remove(item, 'resolved_in_terminal');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // never parsed and the pane is not visibly working: unreadable, not gone
|
||||||
}
|
}
|
||||||
item.context = context;
|
item.context = context;
|
||||||
item.options = options;
|
item.options = options;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This session's pane started moving again": re-verify its pending DIALOG
|
||||||
|
* item against the screen and resolve it if the dialog is gone.
|
||||||
|
*
|
||||||
|
* The staleness check itself lived only in `GET /api/approvals`, which
|
||||||
|
* nothing calls while a page is open (`seedApprovals()` runs on init and
|
||||||
|
* reconnect), so a dialog answered in the terminal kept its red tab alert for
|
||||||
|
* the whole rest of the turn. The `working` signal is exactly the moment an
|
||||||
|
* answer lands, and routing it through `verifyStillAnswerable` is what makes
|
||||||
|
* it safe to act on for a permission/question item: `working` is heuristic
|
||||||
|
* and can flap, but it only decides WHEN to look — the pane decides the
|
||||||
|
* outcome, and an unreadable capture keeps the alert.
|
||||||
|
*
|
||||||
|
* Cheap by construction: a Map miss unless a dialog item is actually pending,
|
||||||
|
* and the item is gone after the first successful resolve.
|
||||||
|
*/
|
||||||
|
resolveIfDialogGone(sessionId: string): void {
|
||||||
|
const item = this.getForSession(sessionId);
|
||||||
|
if (!item || item.kind === 'idle') return;
|
||||||
|
this.verifyStillAnswerable(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Pending item for a session, TTL-checked. */
|
/** Pending item for a session, TTL-checked. */
|
||||||
getForSession(sessionId: string): ApprovalItem | undefined {
|
getForSession(sessionId: string): ApprovalItem | undefined {
|
||||||
const item = this.items.get(sessionId);
|
const item = this.items.get(sessionId);
|
||||||
@@ -359,12 +415,27 @@ export class ApprovalInbox {
|
|||||||
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
for (const timer of this.recaptureTimers.values()) clearTimeout(timer);
|
for (const timers of this.itemTimers.values()) for (const timer of timers) clearTimeout(timer);
|
||||||
this.recaptureTimers.clear();
|
this.itemTimers.clear();
|
||||||
this.items.clear();
|
this.items.clear();
|
||||||
this.captures.clear();
|
this.captures.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` after `delayMs` if the item is still the live one for its session,
|
||||||
|
* tracking the timer so `remove()`/`stop()` can cancel it.
|
||||||
|
*/
|
||||||
|
private scheduleForItem(item: ApprovalItem, delayMs: number, fn: () => void): void {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const timers = this.itemTimers.get(item.id)?.filter((t) => t !== timer) ?? [];
|
||||||
|
if (timers.length > 0) this.itemTimers.set(item.id, timers);
|
||||||
|
else this.itemTimers.delete(item.id);
|
||||||
|
if (this.items.get(item.sessionId)?.id !== item.id) return;
|
||||||
|
fn();
|
||||||
|
}, delayMs);
|
||||||
|
this.itemTimers.set(item.id, [...(this.itemTimers.get(item.id) ?? []), timer]);
|
||||||
|
}
|
||||||
|
|
||||||
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
||||||
if (!capture) return;
|
if (!capture) return;
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
@@ -377,17 +448,30 @@ export class ApprovalInbox {
|
|||||||
if (!context) return;
|
if (!context) return;
|
||||||
item.context = context;
|
item.context = context;
|
||||||
// Idle prompts are not dialogs; never offer digit answers for them.
|
// Idle prompts are not dialogs; never offer digit answers for them.
|
||||||
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
|
if (item.kind === 'idle') return;
|
||||||
|
const options = parseDialogOptions(context);
|
||||||
|
// ⚠️ ADD-ONLY: a re-capture that parses NOTHING must never erase options a
|
||||||
|
// previous capture found. Claude Code delays the Notification hook behind
|
||||||
|
// the dialog (measured 6s here, up to ~30s), so the 600ms re-capture very
|
||||||
|
// often lands AFTER the user has already answered in the terminal, on a
|
||||||
|
// frame with no dialog in it. Clearing the field there was the whole bug:
|
||||||
|
// `verifyStillAnswerable` reads a MISSING `options` as "never parsed" and
|
||||||
|
// keeps such an item answerable by design, so a cleared field made the item
|
||||||
|
// permanently unsweepable — the red "needs you" alert then survived every
|
||||||
|
// `GET /api/approvals` and every page reload and only went away on `stop`
|
||||||
|
// (owner report 2026-08-20: a confirmed question left a tab flowing red for
|
||||||
|
// ~8 minutes while the turn ran on), and the stale card still accepted an
|
||||||
|
// answer, typing a bare `1` into a composer with no dialog under it.
|
||||||
|
// Keeping the parse means a later capture is CONCLUSIVE: options present +
|
||||||
|
// fresh frame without them == answered in the terminal.
|
||||||
|
if (options) item.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
||||||
this.items.delete(item.sessionId);
|
this.items.delete(item.sessionId);
|
||||||
this.captures.delete(item.sessionId);
|
this.captures.delete(item.sessionId);
|
||||||
const timer = this.recaptureTimers.get(item.id);
|
for (const timer of this.itemTimers.get(item.id) ?? []) clearTimeout(timer);
|
||||||
if (timer) {
|
this.itemTimers.delete(item.id);
|
||||||
clearTimeout(timer);
|
|
||||||
this.recaptureTimers.delete(item.id);
|
|
||||||
}
|
|
||||||
if (!this.stopped) {
|
if (!this.stopped) {
|
||||||
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_onHookElicitationComplete(data) {
|
_onHookElicitationComplete(data) {
|
||||||
// Question answered in the terminal: clear the action alert without
|
// Question answered in the terminal: clear the action alert without
|
||||||
// waiting for `stop` (the turn may keep running for a long time).
|
// waiting for `stop` (the turn may keep running for a long time).
|
||||||
|
// ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS,
|
||||||
|
// which resolves a session's pending item whatever its kind. An
|
||||||
|
// AskUserQuestion dialog arrives as `permission_prompt` (only MCP
|
||||||
|
// elicitation is `elicitation_dialog`), so clearing just the elicitation
|
||||||
|
// entry left the red alert armed on exactly the dialog these events are
|
||||||
|
// most often about. Normally the server's `approval:resolved` broadcast
|
||||||
|
// clears it too; this is the path that still works when the store holds no
|
||||||
|
// item for the session (restart, superseded).
|
||||||
if (data.sessionId) {
|
if (data.sessionId) {
|
||||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||||
|
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -219,10 +219,18 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
// An idle-prompt inbox item means "composer is waiting"; any working
|
// An idle-prompt inbox item means "composer is waiting"; any working
|
||||||
// transition means input arrived, so the item is moot. ONLY the idle
|
// transition means input arrived, so the item is moot. ONLY the idle
|
||||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||||
// pending permission/question dialog on it would false-clear real
|
// pending permission/question dialog on the signal ALONE would
|
||||||
// approvals (those resolve via stop / elicitation hooks / answer-time
|
// false-clear real approvals.
|
||||||
// re-capture instead).
|
|
||||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||||
|
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||||
|
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||||
|
// the screen and resolves only when the dialog is really gone. Without
|
||||||
|
// this, answering a dialog in the terminal left its red "needs you" alert
|
||||||
|
// armed for the rest of the turn, because the only other staleness check
|
||||||
|
// lives in `GET /api/approvals` and nothing calls that while a page is
|
||||||
|
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||||
|
// minutes away.
|
||||||
|
approvalInbox.resolveIfDialogGone(session.id);
|
||||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||||
// Full state ride-along: the home screens sort the running group on
|
// Full state ride-along: the home screens sort the running group on
|
||||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ const ASK_USER_QUESTION_FRAME = [
|
|||||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||||
].join('\n');
|
].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) {
|
function collect(inbox: ApprovalInbox) {
|
||||||
const pending: ApprovalItem[] = [];
|
const pending: ApprovalItem[] = [];
|
||||||
const updated: 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', () => {
|
it('stop() clears items and silences events', () => {
|
||||||
const { resolved } = collect(inbox);
|
const { resolved } = collect(inbox);
|
||||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
|||||||
Reference in New Issue
Block a user