mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge
feat(approvals): let a session watching its own background work keep quiet (#468) # Conflicts: # src/config/cli-registry/stock.ts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -122,6 +122,49 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional watchingLine to the same guard', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLine: '(a+)+b',
|
||||
};
|
||||
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
|
||||
});
|
||||
|
||||
it('bounds how far up the screen a config file may search', () => {
|
||||
// The window is the injection guard: every row it adds is another row the agent
|
||||
// itself may be able to write, and the label is what silences an idle alert.
|
||||
for (const lines of [0, 9, 2.5]) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLine: 'chip (\\d+)',
|
||||
watchingLines: lines,
|
||||
};
|
||||
}, 'a config file must not be able to widen the search to the whole pane');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a window with no pattern to bound', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLines: 3,
|
||||
};
|
||||
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
|
||||
});
|
||||
|
||||
it('accepts every shipped watchingLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.watchingLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('no shell text can reach the command line', () => {
|
||||
|
||||
@@ -356,3 +356,50 @@ describe('home screens: one order, one numbering', () => {
|
||||
expect(branch).not.toContain('this.sessionOrder[idx]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('home sessions column: watching badge', () => {
|
||||
it('carries the label off the session payload', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'watcher', watching: '1 monitor' }, { id: 'plain' }]),
|
||||
sessionOrder: ['watcher', 'plain'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(app.buildHomeSessionRows().map((r: any) => [r.id, r.watching]));
|
||||
expect(rows).toEqual({ watcher: '1 monitor', plain: '' });
|
||||
});
|
||||
|
||||
it("builds the badge with the phone overview, in the rail's own pill class", () => {
|
||||
// Both home screens must word this badge identically, so the rail borrows the
|
||||
// builder rather than writing a second one — the same arrangement it already has
|
||||
// for state classification and for the stamp wording.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'watcher', watching: '2 shells' }]),
|
||||
sessionOrder: ['watcher'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
|
||||
const badges = collect(row).filter((el: any) => String(el.className).includes('--watching'));
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0].className).toBe('home-sessions-pill home-sessions-pill--watching');
|
||||
expect(badges[0].textContent).toBe('watching');
|
||||
expect(badges[0].title).toBe('Still running in the background: 2 shells');
|
||||
});
|
||||
|
||||
it('draws no badge for a session running nothing in the background', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'plain' }]),
|
||||
sessionOrder: ['plain'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
|
||||
expect(collect(row).filter((el: any) => String(el.className).includes('--watching'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every node under one the builders made, the row itself included. */
|
||||
function collect(node: any): any[] {
|
||||
return [node, ...(node.children || []).flatMap((child: any) => collect(child))];
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@ function fakeElement(): any {
|
||||
type: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
attrs: {} as Record<string, string>,
|
||||
children: [] as any[],
|
||||
setAttribute() {},
|
||||
setAttribute(name: string, value: string) {
|
||||
el.attrs[name] = value;
|
||||
},
|
||||
appendChild(child: any) {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
@@ -485,3 +488,53 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
expect(gate).toContain('isCliAvailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview watching badge', () => {
|
||||
it('carries what the pane says is running in the background', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle', watching: '1 monitor' }), session({ id: 'b', status: 'idle' })],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.watching]));
|
||||
expect(rows).toEqual({ a: '1 monitor', b: '' });
|
||||
});
|
||||
|
||||
it('leaves a session that is ALSO blocked on a dialog in NEEDS YOU', () => {
|
||||
// An agent can arm a monitor and ask the user a question in the same breath, so the
|
||||
// badge adds a fact to the row and never moves it out of the group that says a human
|
||||
// is needed. Only the row's own pill decides that.
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle', watching: '2 shells' })],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
|
||||
expect(model.needsYou[0].pill).toBe('needs you');
|
||||
expect(model.needsYou[0].watching).toBe('2 shells');
|
||||
});
|
||||
|
||||
it('says one word and puts the detail where every surface can reach it', () => {
|
||||
const app = loadOverviewApp();
|
||||
const badge = app._buildWatchingBadge('1 monitor', 'mobile-overview-pill');
|
||||
|
||||
expect(badge.className).toBe('mobile-overview-pill mobile-overview-pill--watching');
|
||||
expect(badge.textContent).toBe('watching');
|
||||
expect(badge.title).toBe('Still running in the background: 1 monitor');
|
||||
// A phone has no hover target and a screen reader reads neither the class nor the
|
||||
// tooltip, so the label has to be here too or this surface says only "watching".
|
||||
expect(badge.attrs['aria-label']).toBe('Still running in the background: 1 monitor');
|
||||
});
|
||||
|
||||
it('takes the pill class of whichever surface asks for it', () => {
|
||||
// The phone's pill styles live inside a media query the desktop rail never enters,
|
||||
// so the rail passes its own base class and gets the same badge in its own clothes.
|
||||
const app = loadOverviewApp();
|
||||
expect(app._buildWatchingBadge('1 shell', 'home-sessions-pill').className).toBe(
|
||||
'home-sessions-pill home-sessions-pill--watching'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -22,6 +22,21 @@ describe('session listener wiring', () => {
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
|
||||
it('pushes the session state when the watching label changes on its own', () => {
|
||||
// The badge appears on the idle transition, which broadcasts anyway. It goes AWAY
|
||||
// when the background work ends, and a CLI can do that without taking a turn — codex
|
||||
// repaints its background-terminal row away and stays idle — so nothing else fires
|
||||
// and every open page would keep drawing a badge the server had already dropped.
|
||||
const session = new Session({ id: 'wiring-watching-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const broadcastSessionStateDebounced = vi.fn();
|
||||
const deps = { broadcastSessionStateDebounced } as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.watchingChanged();
|
||||
|
||||
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
|
||||
});
|
||||
|
||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
|
||||
@@ -73,3 +73,53 @@ describe('vertical session navigation UX contract', () => {
|
||||
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
|
||||
});
|
||||
});
|
||||
|
||||
describe('watching badge on a rich session row', () => {
|
||||
it('reads the label off the session payload', () => {
|
||||
expect(app).toContain("watching: typeof session.watching === 'string' ? session.watching : ''");
|
||||
});
|
||||
|
||||
it('renders it beside the state pill rather than in place of it', () => {
|
||||
// A session can be watching a monitor AND holding a question for the user, so the
|
||||
// pill that says which one still decides the row; this badge only adds a fact.
|
||||
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
|
||||
const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {'));
|
||||
expect(body).toContain('tab-pill tab-pill--${escapeHtml(row.state)}');
|
||||
expect(body).toContain('tab-pill tab-pill--watching');
|
||||
expect(body).toContain('Still running in the background:');
|
||||
});
|
||||
|
||||
it('re-renders the row when the background work changes', () => {
|
||||
// The meta line is rebuilt only when this signature moves, so a badge left out of
|
||||
// it would appear and disappear a render late, or not at all.
|
||||
expect(app).toContain(
|
||||
'const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes the label everywhere it reaches markup', () => {
|
||||
// `watching` is pane-derived and a config-supplied pattern decides what its capture
|
||||
// group holds, so every interpolation of it into HTML has to go through escapeHtml().
|
||||
// The row is installed with innerHTML, which makes an unescaped quote in that
|
||||
// attribute an injection rather than a cosmetic bug.
|
||||
expect(app).toContain('${richRow.createdAt}:${escapeHtml(richRow.watching)}"');
|
||||
expect(app).not.toContain('${richRow.createdAt}:${richRow.watching}"');
|
||||
});
|
||||
|
||||
it('words the tooltip exactly as the phone overview does', () => {
|
||||
// Both files build this sentence themselves, deliberately, so that a stale cached
|
||||
// module still renders a complete row. Substring-matching the prefix would let the
|
||||
// two drift; the whole sentence is what has to agree.
|
||||
const overview = readFileSync(resolve(publicDir, 'mobile-overview.js'), 'utf8');
|
||||
expect(overview).toContain("'Still running in the background: ' + label");
|
||||
expect(app).toContain('`Still running in the background: ${row.watching}`');
|
||||
});
|
||||
|
||||
it('colours it with the accent, never with the two colours that mean a human is needed', () => {
|
||||
const rule = styles.slice(styles.indexOf('.tab-pill--watching'));
|
||||
const block = rule.slice(0, rule.indexOf('}'));
|
||||
expect(block).toContain('var(--accent)');
|
||||
expect(block).not.toContain('var(--red)');
|
||||
expect(block).not.toContain('var(--yellow)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* The badge a session wears while work it started in the background is still running.
|
||||
*
|
||||
* The bug this pins: an agent that arms a monitor, backgrounds a shell or hands work to a
|
||||
* cloud session is told to end its turn, so the pane falls quiet, Claude Code's idle
|
||||
* notification arrives a minute later, and every Codeman surface files the session under
|
||||
* NEEDS YOU. Nothing wants the user there. The CLI itself says so on the last row of its
|
||||
* screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), and reading that row is
|
||||
* what tells a session waiting for its own background work from one waiting for a human.
|
||||
*
|
||||
* The pane fixtures below are verbatim captures (`tmux -L codeman capture-pane -p`) from a
|
||||
* live Claude Code 2.1.278 session on 2026-09-21.
|
||||
*/
|
||||
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,
|
||||
MAX_WATCHING_LABEL_CHARS,
|
||||
IDLE_SILENCE_MS,
|
||||
} from '../src/session-activity.js';
|
||||
|
||||
/** The registry's own patterns, which are what every consumer runs. */
|
||||
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_WATCHING = compileVersionRegex(getCli('codex')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_TAIL = getCli('codex')!.capabilities.workDetect!.watchingLines!;
|
||||
|
||||
/**
|
||||
* The foot of a Codex pane, verbatim (codex-cli 0.154.0, 2026-09-22). Codex does not
|
||||
* write on its last row: the status line is there, the composer above it, and the
|
||||
* background-terminal row above that, which is why codex declares its own window.
|
||||
*/
|
||||
const CODEX_STATUS =
|
||||
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left';
|
||||
const CODEX_WITH_TERMINAL = [
|
||||
'• OK',
|
||||
'',
|
||||
' 1 background terminal running · /ps to view · /stop to close',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
const CODEX_STOPPED = [
|
||||
'• OK',
|
||||
'',
|
||||
'• Stopping all background terminals.',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** The bottom of a Claude pane: composer, the user's status line, the footer row. */
|
||||
function pane(footer: string, body = ''): string {
|
||||
return (
|
||||
body +
|
||||
'╭──────────────────────────────────────╮\n' +
|
||||
'│ ❯ │\n' +
|
||||
'╰──────────────────────────────────────╯\n' +
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%\n' +
|
||||
` ${footer}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const WITH_MONITOR = pane('⏵⏵ bypass permissions on · 1 monitor · ← for agents');
|
||||
const WITH_SHELL = pane('⏵⏵ bypass permissions on · 1 shell · ← for agents');
|
||||
const NOTHING_RUNNING = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents');
|
||||
|
||||
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
||||
const COMPOSER_REPAINT =
|
||||
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
_detectInteractiveActivity(data: string): void;
|
||||
};
|
||||
|
||||
/** One PTY chunk, exactly as the interactive handler sees it. */
|
||||
function feed(session: Session, data: string): void {
|
||||
const internals = session as unknown as SessionInternals;
|
||||
internals._handleTerminalOutput(data);
|
||||
internals._detectInteractiveActivity(data);
|
||||
}
|
||||
|
||||
/** A session whose mux reports a fixed (or scripted) screen for the pane probe to read. */
|
||||
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
|
||||
const read = typeof screen === 'function' ? screen : () => screen;
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneText: () => read(),
|
||||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode,
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/** Codex's own composer repaint, the frame that arms its idle confirmation. */
|
||||
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||||
|
||||
/** Run one turn and let it end, which is when the probe reads the screen. */
|
||||
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, repaint);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
}
|
||||
|
||||
describe('watchingLabel', () => {
|
||||
it('reads the label off the footer row', () => {
|
||||
expect(watchingLabel(WITH_MONITOR, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
expect(watchingLabel(WITH_SHELL, CLAUDE_WATCHING)).toBe('1 shell');
|
||||
});
|
||||
|
||||
it('reads every kind of background work the CLI names', () => {
|
||||
const labels = [
|
||||
'2 monitors',
|
||||
'3 shells',
|
||||
'1 cloud session',
|
||||
'2 cloud sessions',
|
||||
'1 local agent',
|
||||
'4 background tasks',
|
||||
'1 MCP task',
|
||||
'1 background dynamic workflow',
|
||||
'2 remote dynamic workflows',
|
||||
'1 Artifact comment monitor',
|
||||
'2 teams',
|
||||
];
|
||||
for (const label of labels) {
|
||||
expect(watchingLabel(pane(`⏵⏵ bypass permissions on · ${label} · ← for agents`), CLAUDE_WATCHING)).toBe(label);
|
||||
}
|
||||
});
|
||||
|
||||
it('says nothing about a pane that is running nothing', () => {
|
||||
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(null, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores the same words in the transcript above the composer', () => {
|
||||
// The whole reason the search is confined to the foot of the screen: a session that
|
||||
// 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' +
|
||||
' 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();
|
||||
});
|
||||
|
||||
it('looks no further up the screen than the tail it declares', () => {
|
||||
const chip = '⏵⏵ bypass permissions on · 1 monitor · ← for agents';
|
||||
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${below}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a chip on the row above the footer, which the agent can write', () => {
|
||||
// The status line is one row up, its text comes from a `statusLine` command, and a
|
||||
// session running with permissions bypassed can write that command into
|
||||
// `.claude/settings.json` in its own workspace. The window is what keeps that row
|
||||
// out, so this is the test that would fail if somebody widened it.
|
||||
const forged = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents').replace(
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%',
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11% · 1 monitor'
|
||||
);
|
||||
expect(watchingLabel(forged, CLAUDE_WATCHING)).toBeNull();
|
||||
// And with the window widened by one, the same screen does match — which is the
|
||||
// whole reason the default is one row.
|
||||
expect(watchingLabel(forged, CLAUDE_WATCHING, 2)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('keeps Claude on the default window, because its chip is the last row', () => {
|
||||
expect(getCli('claude')?.capabilities.workDetect?.watchingLines).toBeUndefined();
|
||||
expect(WATCHING_TAIL_LINES).toBe(1);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// compileVersionRegex() never sets `g`, but a test or a reloaded config might, and a
|
||||
// sticky lastIndex would make the same screen match every other call.
|
||||
const global = new RegExp(CLAUDE_WATCHING.source, 'g');
|
||||
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
|
||||
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session.watching', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('carries what the pane reported once the turn ends', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_MONITOR);
|
||||
expect(session.watching).toBeNull();
|
||||
|
||||
runAndSettle(session);
|
||||
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('lets the badge go when the background work is over', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text);
|
||||
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
|
||||
screen.text = NOTHING_RUNNING;
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('announces the change, because the session status does not move with it', () => {
|
||||
// Measured on codex: a background terminal finishing repaints the row away and the
|
||||
// session is idle before and after, so no other event fires. Without this one the
|
||||
// server drops the label and every open page goes on drawing the badge.
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text);
|
||||
const changes: (string | null)[] = [];
|
||||
session.on('watchingChanged', () => changes.push(session.watching));
|
||||
|
||||
runAndSettle(session);
|
||||
expect(changes).toEqual(['1 monitor']);
|
||||
|
||||
// A repaint that carries the composer glyph but no chip: the pane went quiet again
|
||||
// without a turn, which is exactly the case the event exists for.
|
||||
screen.text = NOTHING_RUNNING;
|
||||
feed(session, COMPOSER_REPAINT);
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
expect(changes).toEqual(['1 monitor', null]);
|
||||
expect(session.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('says nothing while the answer stays the same', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_MONITOR);
|
||||
const changes: (string | null)[] = [];
|
||||
session.on('watchingChanged', () => changes.push(session.watching));
|
||||
|
||||
runAndSettle(session);
|
||||
runAndSettle(session);
|
||||
runAndSettle(session);
|
||||
expect(changes).toEqual(['1 monitor']);
|
||||
});
|
||||
|
||||
it('keeps its last answer when the screen cannot be read', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen: { text: string | null } = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text as string);
|
||||
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
|
||||
// A capture that fails is not evidence that nothing is running, which is the same
|
||||
// rule the working probe applies to its own null.
|
||||
screen.text = null;
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('reads Codex own row, three up from the bottom of its screen', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(CODEX_WITH_TERMINAL, 'codex');
|
||||
runAndSettle(session, CODEX_COMPOSER_REPAINT);
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.watching).toBe('1 background terminal');
|
||||
});
|
||||
|
||||
it('reports nothing for a CLI whose screen nobody has characterised', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
|
||||
const session = withFakePane(WITH_MONITOR, 'gemini');
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('never reads another CLI screen', () => {
|
||||
vi.useFakeTimers();
|
||||
// Each pattern is anchored on chrome its own CLI draws, so neither can fire on the
|
||||
// other's pane. A shared fallback would have both reading a screen nobody measured.
|
||||
const codexOnClaudeScreen = withFakePane(WITH_MONITOR, 'codex');
|
||||
runAndSettle(codexOnClaudeScreen, CODEX_COMPOSER_REPAINT);
|
||||
expect(codexOnClaudeScreen.watching).toBeNull();
|
||||
|
||||
const claudeOnCodexScreen = withFakePane(CODEX_WITH_TERMINAL, 'claude');
|
||||
runAndSettle(claudeOnCodexScreen);
|
||||
expect(claudeOnCodexScreen.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('rides along on the payload every session surface reads', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_SHELL);
|
||||
runAndSettle(session);
|
||||
|
||||
expect(session.toLightDetailedState().watching).toBe('1 shell');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the row Codex draws', () => {
|
||||
it('reads the label, and only while a terminal is running', () => {
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
|
||||
expect(watchingLabel(CODEX_STOPPED, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts terminals', () => {
|
||||
const three = CODEX_WITH_TERMINAL.replace('1 background terminal running', '3 background terminals running');
|
||||
expect(watchingLabel(three, CODEX_WATCHING, CODEX_TAIL)).toBe('3 background terminals');
|
||||
});
|
||||
|
||||
it('needs the window Codex declares: its row is not the last one', () => {
|
||||
// Pins WHY `watchingLines` exists. Claude's default of two rows reaches the status
|
||||
// line and the composer, and Codex's row sits one further up.
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, 2)).toBeNull();
|
||||
expect(CODEX_TAIL).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('refuses a mention that is not the whole row', () => {
|
||||
// The pattern matches Codex's row end to end, so prose about background terminals —
|
||||
// including prose quoting part of the row — is not enough.
|
||||
for (const line of [
|
||||
'• I left 1 background terminal running for you.',
|
||||
' 1 background terminal running · /ps to view',
|
||||
' see: 1 background terminal running · /ps to view · /stop to close',
|
||||
]) {
|
||||
const claim = CODEX_STOPPED.replace('• Stopping all background terminals.', line);
|
||||
expect(watchingLabel(claim, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('CAN be forged by Codex own output, and is contained by Codex having no hooks', () => {
|
||||
// Codex's row is third from the bottom only while a terminal runs; with none running
|
||||
// that slot is the last row of the transcript, which the agent writes. Matching the
|
||||
// complete row raises the bar but closes nothing, so this test states the limitation
|
||||
// rather than a protection the code does not have.
|
||||
const forged = CODEX_STOPPED.replace(
|
||||
'• Stopping all background terminals.',
|
||||
' 1 background terminal running · /ps to view · /stop to close'
|
||||
);
|
||||
expect(watchingLabel(forged, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
|
||||
|
||||
// What makes that cost a wrong badge and nothing more: no hook event from a codex
|
||||
// session reaches the approvals inbox, so there is no idle item to pre-acknowledge
|
||||
// and no alert to silence. A CLI that gains hook signals needs a harder anchor first.
|
||||
expect(getCli('codex')?.capabilities.hooks).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the registry pattern Claude declares', () => {
|
||||
it('is one the config-regex guard accepts', () => {
|
||||
// Same guard as `workingLine`: ~/.codeman/clis.json can set this field, and the
|
||||
// compiled pattern runs over a pane capture on a timer.
|
||||
expect(compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not fire on the status line a user configured', () => {
|
||||
// Plan-usage and context figures live one row above the footer and carry numbers.
|
||||
const statusLine = ' ~/innovi/gtd-board [main] Opus 5 (1M context) high ctx: 10% 5h: 48% (32m) 7d: 15% (6d10h)';
|
||||
expect(CLAUDE_WATCHING.test(statusLine)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,24 @@ describe('approvalCard', () => {
|
||||
expect(card.hint).toBe('p to reply');
|
||||
});
|
||||
|
||||
it('says why an acknowledged idle prompt is quiet, in the drawer own words', () => {
|
||||
// The session is watching work it started itself. Asking for a reply would be the
|
||||
// same false alarm the acknowledgement exists to remove, so the card states the
|
||||
// reason and drops out of the warning vocabulary — while staying answerable.
|
||||
const card = approvalCard(
|
||||
item({
|
||||
kind: 'idle',
|
||||
message: 'Claude is waiting for your input',
|
||||
options: undefined,
|
||||
acknowledgedAt: 1_700_000_000_000,
|
||||
acknowledgedReason: 'watching 1 monitor',
|
||||
})
|
||||
);
|
||||
expect(card.tone).toBe('info');
|
||||
expect(card.title).toBe('quiet, watching 1 monitor');
|
||||
expect(card.hint).toBe('p to reply');
|
||||
});
|
||||
|
||||
it('drops the approve/deny-only hint when the frame did not parse', () => {
|
||||
const card = approvalCard(item({ options: undefined }));
|
||||
expect(card.options).toEqual([]);
|
||||
@@ -81,6 +99,12 @@ describe('approvalTone', () => {
|
||||
expect(approvalTone(item({ kind: 'question' }))).toBe('err');
|
||||
expect(approvalTone(item({ kind: 'idle' }))).toBe('warn');
|
||||
});
|
||||
|
||||
it('is neither for a prompt that opened acknowledged', () => {
|
||||
expect(approvalTone(item({ kind: 'idle', acknowledgedReason: 'watching 2 shells' }))).toBe('info');
|
||||
// A dialog stays red whatever else the session started.
|
||||
expect(approvalTone(item({ kind: 'permission', acknowledgedReason: 'watching 2 shells' }))).toBe('err');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvalAnswerForKey', () => {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/
|
||||
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
|
||||
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
|
||||
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
|
||||
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
||||
import {
|
||||
composerCursorCell,
|
||||
detectGlyphTier,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
formatPlanUsage,
|
||||
formatTokens,
|
||||
glyphsFor,
|
||||
pendingApprovalCount,
|
||||
renderFrame,
|
||||
rowLabel,
|
||||
type TuiRenderOptions,
|
||||
@@ -682,3 +684,33 @@ describe('the unicode glyph set is safe to render', () => {
|
||||
expect(every.join('')).not.toContain('\u270B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingApprovalCount', () => {
|
||||
const prompt = (over: Partial<ApprovalItem>): ApprovalItem => ({
|
||||
id: 'bbb2:1',
|
||||
sessionId: 'bbb2',
|
||||
sessionName: 'w6-docs',
|
||||
kind: 'idle',
|
||||
createdAt: NOW - 30_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('counts a prompt nobody has seen', () => {
|
||||
const model = fixture();
|
||||
model.setApprovals([prompt({})]);
|
||||
expect(pendingApprovalCount(model)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count one whose alert is already spent', () => {
|
||||
// Both ways an item gets acknowledged: a human opening the session elsewhere, and the
|
||||
// inbox opening it that way for a session watching its own background work. The row
|
||||
// has left NEEDS YOU by the same flag, so a number in the header would point at a
|
||||
// group the reader can see is empty.
|
||||
const model = fixture();
|
||||
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000 })]);
|
||||
expect(pendingApprovalCount(model)).toBe(0);
|
||||
|
||||
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000, acknowledgedReason: 'watching 1 monitor' })]);
|
||||
expect(pendingApprovalCount(model)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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