mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f2cde2db7
commit
74884a20eb
@@ -330,6 +330,58 @@ describe('ApprovalInbox', () => {
|
||||
expect(inbox.getById(second.id)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('a session watching its own background work', () => {
|
||||
it('opens its idle prompt already acknowledged, and says why', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBe(item.createdAt);
|
||||
expect(item.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('keeps the prompt pending and answerable: only its alert is spent', () => {
|
||||
// Acknowledging rather than skipping creation is what makes a wrong label cheap.
|
||||
// The prompt is real either way, and this way it is still in the drawer, still
|
||||
// answerable and still Read My Mind context.
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '2 shells' });
|
||||
expect(inbox.listPending().map((i) => i.id)).toEqual([item.id]);
|
||||
expect(inbox.getForSession('s1')?.id).toBe(item.id);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('never pre-acknowledges a dialog that blocks the agent', () => {
|
||||
// A permission or question dialog blocks the turn whatever else the agent started,
|
||||
// so watching says nothing about whether a human is needed.
|
||||
for (const kind of ['permission', 'question'] as const) {
|
||||
const item = inbox.notePrompt({ sessionId: `s-${kind}`, sessionName: 'w1', kind, watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(item.acknowledgedReason).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an ordinary idle prompt alone', () => {
|
||||
for (const watching of [undefined, null, '']) {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-arms by itself once the background work is over', () => {
|
||||
// The next prompt supersedes this one and is built fresh, so nothing has to
|
||||
// remember to clear the flag when the monitor ends.
|
||||
const watched = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(watched.acknowledgedAt).toBeDefined();
|
||||
const after = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
|
||||
expect(after.id).not.toBe(watched.id);
|
||||
expect(after.acknowledgedAt).toBeUndefined();
|
||||
expect(after.acknowledgedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cannot be acknowledged a second time by a human opening the session', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(inbox.acknowledge('s1')).toBeUndefined();
|
||||
expect(inbox.getById(item.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyStillAnswerable', () => {
|
||||
it('resolves the item and refuses when a parsed dialog left the screen', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
|
||||
@@ -35,6 +35,12 @@ export class MockSession extends EventEmitter {
|
||||
/** `null` once the PTY is gone (or before it has ever started) — see `pid` in Session. */
|
||||
pid: number | null = 12345;
|
||||
isWorking: boolean = false;
|
||||
/**
|
||||
* Mirrors Session.watching — what the pane's footer says is still running in the
|
||||
* background. An idle prompt from such a session opens acknowledged, so the routes
|
||||
* need to be able to set it.
|
||||
*/
|
||||
watching: string | null = null;
|
||||
private _activeChildProcesses: { pid: number; command: string }[] = [];
|
||||
ralphTracker: null = null;
|
||||
writeBuffer: string[] = [];
|
||||
|
||||
@@ -325,6 +325,65 @@ describe('approval routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an idle prompt from a session watching its own background work', () => {
|
||||
beforeEach(() => {
|
||||
session.terminalBuffer = 'claude> waiting at the composer';
|
||||
session.watching = '1 monitor';
|
||||
});
|
||||
|
||||
it('opens acknowledged, so no surface has an alert to raise', async () => {
|
||||
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item).toMatchObject({ kind: 'idle', acknowledgedReason: 'watching 1 monitor' });
|
||||
expect(item.acknowledgedAt).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('tells a live page why, so it declines to arm the alert', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:idle_prompt');
|
||||
expect(broadcast?.[1]).toMatchObject({ acknowledgedReason: 'watching 1 monitor' });
|
||||
});
|
||||
|
||||
it('sends no push', async () => {
|
||||
// The loudest surface, and the one a false alarm is hardest to ignore on.
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:idle_prompt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays answerable from the drawer', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'text', text: 'carry on' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.writeBuffer.join('')).toContain('carry on');
|
||||
});
|
||||
|
||||
it('still raises a permission dialog from the same session', async () => {
|
||||
// Watching says nothing about a dialog: that one blocks the agent outright.
|
||||
session.terminalBuffer = PERMISSION_DIALOG;
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(item.acknowledgedReason).toBeUndefined();
|
||||
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||
expect(broadcast?.[1]).not.toMatchObject({ acknowledgedReason: expect.any(String) });
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt')).toBeDefined();
|
||||
});
|
||||
|
||||
it('alerts normally again once the background work is over', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
session.watching = null;
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.filter((c) => c[0] === 'hook:idle_prompt')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('viewing a session acknowledges its idle prompt (item stays pending) and broadcasts it', async () => {
|
||||
session.terminalBuffer = 'claude> waiting at the composer';
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
|
||||
@@ -15,7 +15,12 @@ import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
||||
import { watchingLabel, WATCHING_TAIL_LINES, IDLE_SILENCE_MS } from '../src/session-activity.js';
|
||||
import {
|
||||
watchingLabel,
|
||||
WATCHING_TAIL_LINES,
|
||||
MAX_WATCHING_LABEL_CHARS,
|
||||
IDLE_SILENCE_MS,
|
||||
} from '../src/session-activity.js';
|
||||
|
||||
/** The registry's own pattern for Claude, which is what every consumer runs. */
|
||||
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
|
||||
@@ -112,7 +117,7 @@ describe('watchingLabel', () => {
|
||||
// PRINTS "1 monitor" (this one has been discussing exactly that) is not running one.
|
||||
const transcript =
|
||||
'> does Codeman know about watching?\n' +
|
||||
'⏺ The footer says 1 monitor while a monitor is armed, and 2 shells for\n' +
|
||||
'⏺ The footer says · 1 monitor · while a monitor is armed, and · 2 shells · for\n' +
|
||||
' backgrounded commands. Codeman reads neither today.\n' +
|
||||
' Nothing else on the screen means background work is running.\n';
|
||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · ← for agents', transcript), CLAUDE_WATCHING)).toBeNull();
|
||||
@@ -120,11 +125,31 @@ describe('watchingLabel', () => {
|
||||
|
||||
it('looks no further up the screen than the tail it declares', () => {
|
||||
const chip = '⏵⏵ bypass permissions on · 1 monitor · ← for agents';
|
||||
const blanks = Array(WATCHING_TAIL_LINES).fill(' still here').join('\n');
|
||||
// Blank lines are dropped before the tail is taken, so padding with them must not
|
||||
// push the footer out of range.
|
||||
const below = Array(WATCHING_TAIL_LINES).fill(' still here').join('\n');
|
||||
// Blank lines are dropped before the tail is taken, so a pane padded with them must
|
||||
// still read its own footer.
|
||||
expect(watchingLabel(`${chip}\n\n\n\n\n\n`, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
expect(watchingLabel(`${chip}\n${blanks}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(`${chip}\n${below}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a label the footer did not separate, which is the injection guard', () => {
|
||||
// The pattern anchors on the `·` the footer joins its items with. Without that
|
||||
// anchor an agent could silence its own idle alert by printing the words, since the
|
||||
// only rows it cannot write are the footer and the status line.
|
||||
expect(watchingLabel(pane('1 monitor'), CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(pane('running 2 shells for the build'), CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 monitor'), CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('reads a coloured footer, because a capture may carry ANSI', () => {
|
||||
const coloured = pane('\u001b[2m⏵⏵ bypass permissions on\u001b[0m · \u001b[36m1 monitor\u001b[0m · ← for agents');
|
||||
expect(watchingLabel(coloured, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('caps the label, because it ends up on a badge and in an approval card', () => {
|
||||
const long = `· ${'9'.repeat(MAX_WATCHING_LABEL_CHARS * 2)} monitors`;
|
||||
const label = watchingLabel(pane(`⏵⏵ bypass permissions on ${long} · ← for agents`), CLAUDE_WATCHING);
|
||||
expect(label?.length).toBe(MAX_WATCHING_LABEL_CHARS);
|
||||
});
|
||||
|
||||
it('survives a pattern handed to it with the global flag set', () => {
|
||||
|
||||
@@ -71,6 +71,32 @@ describe('classifySession', () => {
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting');
|
||||
});
|
||||
|
||||
it('stops an ACKNOWLEDGED prompt deciding the row', () => {
|
||||
// Acknowledgement means the alert this prompt armed has been spent, either because
|
||||
// a human opened the session elsewhere or because the inbox opened the item that way
|
||||
// for a session watching its own background work. The web has honoured that since
|
||||
// acknowledgement existed; this gate used to read past it, so an alert cleared on a
|
||||
// phone stayed lit here alone.
|
||||
const quiet = session({ sessionId: 'a', status: 'idle' });
|
||||
const seen = approval({ sessionId: 'a', kind: 'idle', acknowledgedAt: NOW - 1_000 });
|
||||
expect(classifySession(quiet, seen)).toBe('idle');
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'busy' }), seen)).toBe('working');
|
||||
});
|
||||
|
||||
it('keeps a blocking dialog lit whatever its acknowledgement says', () => {
|
||||
// `acknowledge()` is idle-only by construction, so this cannot happen through the
|
||||
// routes. It is pinned because the cost of the two being wired together later is a
|
||||
// permission dialog that stops asking.
|
||||
const row = session({ sessionId: 'a' });
|
||||
const at = NOW - 1_000;
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission', acknowledgedAt: at }))).toBe(
|
||||
'blocked-permission'
|
||||
);
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'question', acknowledgedAt: at }))).toBe(
|
||||
'blocked-question'
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a row the server no longer has live as history', () => {
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent');
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent');
|
||||
@@ -78,6 +104,46 @@ describe('classifySession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an acknowledged prompt on a watching session', () => {
|
||||
const sessions = [session({ sessionId: 'watcher', status: 'idle', lastActivityAt: NOW - 120_000 })];
|
||||
const approvals = approvalMap([
|
||||
approval({
|
||||
sessionId: 'watcher',
|
||||
kind: 'idle',
|
||||
createdAt: NOW - 30_000,
|
||||
acknowledgedAt: NOW - 30_000,
|
||||
acknowledgedReason: 'watching 1 monitor',
|
||||
}),
|
||||
]);
|
||||
|
||||
it('raises no alert: the row leaves NEEDS YOU entirely', () => {
|
||||
const groups = groupSessions(buildRows(sessions, approvals));
|
||||
const byKey = Object.fromEntries(groups.map((group) => [group.key, group.rows.map((r) => r.session.sessionId)]));
|
||||
expect(byKey['needs-you']).toEqual([]);
|
||||
expect(byKey['idle']).toEqual(['watcher']);
|
||||
});
|
||||
|
||||
it('keeps the item on the row, because it is still pending and still answerable', () => {
|
||||
const [row] = buildRows(sessions, approvals);
|
||||
expect(row.approval?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('dates the row from the pane going quiet, not from the prompt', () => {
|
||||
// The prompt's age measures the state only while the prompt is what put the row in
|
||||
// it. Reading it here would report "idle 30s" for a session quiet for two minutes.
|
||||
const [row] = buildRows(sessions, approvals);
|
||||
expect(row.since).toBe(NOW - 120_000);
|
||||
});
|
||||
|
||||
it('alerts again once the same session goes quiet for an ordinary reason', () => {
|
||||
// The inbox supersedes the acknowledged item and builds a fresh one, so this is the
|
||||
// next prompt rather than the same one changing its mind.
|
||||
const fresh = approvalMap([approval({ sessionId: 'watcher', kind: 'idle', createdAt: NOW - 1_000 })]);
|
||||
const groups = groupSessions(buildRows(sessions, fresh));
|
||||
expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['watcher']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupSessions', () => {
|
||||
it('always returns the four groups in display order', () => {
|
||||
expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Port: none (pure classifiers + vm-loaded frontend modules — no browser, no server).
|
||||
//
|
||||
// The point of the watching signal is an alert that does NOT fire, so the test that
|
||||
// matters is the negative one. A session that ended its turn because it armed a monitor
|
||||
// or backgrounded a shell gets an idle prompt from Claude Code about a minute later, and
|
||||
// that prompt must reach every surface as a card nobody has to look at rather than as an
|
||||
// alert. The same session holding a permission dialog must still go red everywhere.
|
||||
//
|
||||
// Each surface is exercised through the code it really runs: the TUI classifier, the
|
||||
// live SSE handler in settings-ui.js, the reload seed in approvals-ui.js, and the state
|
||||
// classifier both home screens share.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { ApprovalInbox, type ApprovalItem } from '../src/web/approval-inbox.js';
|
||||
import { buildRows, groupSessions } from '../src/tui/tui-model.js';
|
||||
import type { TuiSessionRow } from '../src/tui/tui-types.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const SESSION = 'watcher-session';
|
||||
|
||||
/** A live unified row for the session under test, quiet at its composer. */
|
||||
function row(overrides: Partial<TuiSessionRow> = {}): TuiSessionRow {
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
sources: ['live'],
|
||||
name: 'watch-probe',
|
||||
mode: 'claude',
|
||||
status: 'idle',
|
||||
workingDir: '/home/dev/case',
|
||||
createdAt: 1_000,
|
||||
lastActivityAt: 2_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The item the server really produces for this case, built by the real inbox rather
|
||||
* than by hand, so a change to how `watching` is honoured breaks this file too.
|
||||
*/
|
||||
function itemFor(watching: string | null): ApprovalItem {
|
||||
const inbox = new ApprovalInbox();
|
||||
const item = inbox.notePrompt({ sessionId: SESSION, sessionName: 'watch-probe', kind: 'idle', watching });
|
||||
inbox.stop();
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Minimal fake DOM node, enough for the handlers these tests drive. */
|
||||
function fakeElement(): Record<string, unknown> {
|
||||
const el: Record<string, unknown> = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
children: [] as unknown[],
|
||||
hidden: false,
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
||||
setAttribute() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
appendChild(child: unknown) {
|
||||
(el.children as unknown[]).push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
interface FrontendApp {
|
||||
pendingHooks: Map<string, Set<string>>;
|
||||
notifications: string[];
|
||||
approvals: Map<string, ApprovalItem>;
|
||||
seeded: ApprovalItem[];
|
||||
setPendingHook(sessionId: string, hook: string): void;
|
||||
clearPendingHooks(sessionId: string, hook?: string): void;
|
||||
_onHookIdlePrompt(data: Record<string, unknown>): void;
|
||||
_onHookPermissionPrompt(data: Record<string, unknown>): void;
|
||||
seedApprovals(): Promise<void>;
|
||||
_mobileOverviewState(session: Record<string, unknown>, hooks?: Set<string>): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three frontend modules that decide whether a prompt becomes an alert, loaded into
|
||||
* one context the way the page loads them. Everything they call that belongs to app.js
|
||||
* is stubbed to record rather than to render.
|
||||
*/
|
||||
function loadFrontend(seed: ApprovalItem[] = []): FrontendApp {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: {},
|
||||
CSS: { escape: (s: string) => s },
|
||||
document: {
|
||||
documentElement: { getAttribute: () => null, dataset: {} },
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
createElement: () => fakeElement(),
|
||||
createElementNS: () => fakeElement(),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => 'desktop' },
|
||||
});
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'approvals-ui.js', 'settings-ui.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
const app = Object.create(CodemanApp.prototype) as FrontendApp & Record<string, unknown>;
|
||||
app.pendingHooks = new Map();
|
||||
app.notifications = [];
|
||||
app.approvals = new Map();
|
||||
app.seeded = seed;
|
||||
app.setPendingHook = (sessionId: string, hook: string) => {
|
||||
if (!app.pendingHooks.has(sessionId)) app.pendingHooks.set(sessionId, new Set());
|
||||
app.pendingHooks.get(sessionId)!.add(hook);
|
||||
};
|
||||
app.clearPendingHooks = (sessionId: string, hook?: string) => {
|
||||
if (!hook) app.pendingHooks.delete(sessionId);
|
||||
else app.pendingHooks.get(sessionId)?.delete(hook);
|
||||
};
|
||||
Object.assign(app, {
|
||||
_notifySession: (_id: string, _level: string, kind: string) => app.notifications.push(kind),
|
||||
_apiJson: async () => ({ approvals: app.seeded }),
|
||||
approvalsInboxEnabled: () => true,
|
||||
renderApprovals: () => {},
|
||||
renderSessionTabs: () => {},
|
||||
loadAppSettingsFromStorage: () => ({}),
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('a watching session raises no alert on any surface', () => {
|
||||
const watched = itemFor('1 monitor');
|
||||
|
||||
it('the store opens the prompt acknowledged, which is what every surface reads', () => {
|
||||
expect(watched.acknowledgedAt).toEqual(expect.any(Number));
|
||||
expect(watched.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('codeman tui leaves the row out of NEEDS YOU', () => {
|
||||
const groups = groupSessions(buildRows([row()], new Map([[SESSION, watched]])));
|
||||
const needsYou = groups.find((group) => group.key === 'needs-you')!;
|
||||
expect(needsYou.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('a live page declines to arm the tab alert and raises no desktop notification', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, acknowledgedReason: watched.acknowledgedReason });
|
||||
expect(app.pendingHooks.get(SESSION)).toBeUndefined();
|
||||
expect(app.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it('a reloading page does not arm it either', async () => {
|
||||
const app = loadFrontend([watched]);
|
||||
await app.seedApprovals();
|
||||
expect(app.pendingHooks.get(SESSION)).toBeUndefined();
|
||||
// The card itself is still there to answer, which is the whole point of
|
||||
// acknowledging the prompt rather than never creating it.
|
||||
expect(app.approvals.get(watched.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('so both home screens classify the session as plainly idle', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, acknowledgedReason: watched.acknowledgedReason });
|
||||
const state = app._mobileOverviewState({ status: 'idle' }, app.pendingHooks.get(SESSION));
|
||||
expect(state).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ordinary idle prompt still alerts everywhere', () => {
|
||||
const plain = itemFor(null);
|
||||
|
||||
it('the store leaves it unacknowledged', () => {
|
||||
expect(plain.acknowledgedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('codeman tui puts the row in NEEDS YOU', () => {
|
||||
const groups = groupSessions(buildRows([row()], new Map([[SESSION, plain]])));
|
||||
expect(groups[0].rows.map((r) => r.session.sessionId)).toEqual([SESSION]);
|
||||
});
|
||||
|
||||
it('a live page arms the tab alert and notifies', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, message: 'Claude is waiting for your input' });
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
|
||||
expect(app.notifications).toEqual(['hook-idle']);
|
||||
});
|
||||
|
||||
it('a reloading page arms it from the seed', async () => {
|
||||
const app = loadFrontend([plain]);
|
||||
await app.seedApprovals();
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
|
||||
});
|
||||
|
||||
it('so both home screens put the session in NEEDS YOU', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION });
|
||||
expect(app._mobileOverviewState({ status: 'idle' }, app.pendingHooks.get(SESSION))).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dialog blocking the agent alerts even while it watches', () => {
|
||||
it('a live page arms and notifies whatever else the session started', () => {
|
||||
// A permission prompt never opens acknowledged (the inbox gates on kind), so the
|
||||
// handler never sees a reason and this is the ordinary path. Pinned because the two
|
||||
// handlers sit side by side and the guard belongs on exactly one of them.
|
||||
const app = loadFrontend();
|
||||
app._onHookPermissionPrompt({ sessionId: SESSION, tool: 'Bash' });
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['permission_prompt']);
|
||||
expect(app.notifications).toEqual(['hook-permission']);
|
||||
});
|
||||
|
||||
it('codeman tui shows it as blocked', () => {
|
||||
const inbox = new ApprovalInbox();
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: SESSION,
|
||||
sessionName: 'watch-probe',
|
||||
kind: 'permission',
|
||||
watching: '1 monitor',
|
||||
});
|
||||
inbox.stop();
|
||||
const [built] = buildRows([row()], new Map([[SESSION, item]]));
|
||||
expect(built.state).toBe('blocked-permission');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user