Files
Codeman/test/screen-blocked.test.ts
Codeman maintainer a35ddd82ba feat(codex): raise a screen-read dialog as BLOCKED, alert-only
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.

It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.

The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.

Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:18:37 +02:00

111 lines
5.2 KiB
TypeScript

/**
* What the server raises for a dialog read off a hook-less CLI's screen (codex), and what
* clears it: `web/screen-blocked.ts`. The session side, and the codex captures it reads,
* are in `codex-blocked-detection.test.ts`.
*
* The contract: exactly what a Claude `permission_prompt` hook raises (the `blocked` wait
* signal, an Approvals Inbox item, the `hook:permission_prompt` broadcast, push and
* webhook, a run-summary row), but ALERT-ONLY, since the dialog was read from text the
* agent writes: the item is `answerInTerminal`, push carries no `approvalId`, and the
* inbox never resolves or answers it on its own.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { raiseScreenBlocked, clearScreenBlocked, screenBlockedMessage } from '../src/web/screen-blocked.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
import { sessionWaits } from '../src/web/session-wait-registry.js';
import { SseEvent } from '../src/web/sse-events.js';
import type { SessionMode } from '../src/types.js';
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
describe('raiseScreenBlocked / clearScreenBlocked', () => {
const SESSION = { id: 'screen-blocked-test', name: 'w9-scratch', mode: 'codex' as SessionMode };
function deps() {
const recordHookEvent = vi.fn();
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => ({ recordHookEvent })),
recordHookEvent,
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION.id, 'session_ended');
sessionWaits.cancelAll(SESSION.id);
});
it('raises what a permission_prompt hook raises, alert-only', async () => {
const d = deps();
const waiting = sessionWaits.waitForSignal(SESSION.id, { until: ['blocked'], timeoutMs: 5000 });
const itemId = raiseScreenBlocked(SESSION, d, fixture('exec-approval.txt'));
expect((await waiting).signal).toBe('blocked');
const item = approvalInbox.getForSession(SESSION.id)!;
expect(item.id).toBe(itemId);
expect(item.kind).toBe('permission');
expect(item.answerInTerminal).toBe(true);
expect(item.message).toBe('Codex is waiting for your answer in the terminal');
// The frame is the card's context, and nothing parsed it into answer buttons.
expect(item.context).toContain('Press enter to confirm or esc to cancel');
expect(item.options).toBeUndefined();
expect(d.broadcast).toHaveBeenCalledWith(
SseEvent.HookPermissionPrompt,
expect.objectContaining({ sessionId: SESSION.id, source: 'screen', approvalId: itemId })
);
expect(d.broadcastSessionStateDebounced).toHaveBeenCalledWith(SESSION.id);
// Push and webhook get the message and NO approvalId, so no Approve/Deny buttons.
const [event, push] = d.sendPushNotifications.mock.calls[0] as [string, Record<string, unknown>];
expect(event).toBe(SseEvent.HookPermissionPrompt);
expect(push).toMatchObject({ sessionId: SESSION.id, sessionName: 'w9-scratch', source: 'screen' });
expect(push.message).toBe(screenBlockedMessage('codex'));
expect(push.approvalId).toBeUndefined();
expect(d.recordHookEvent).toHaveBeenCalledWith('permission_prompt', expect.objectContaining({ source: 'screen' }));
});
it('never steers a keystroke: the inbox item cannot be answered', () => {
raiseScreenBlocked(SESSION, deps(), fixture('exec-approval.txt'));
const item = approvalInbox.getForSession(SESSION.id)!;
// No capture callback, so the inbox's Claude-shaped sweep never resolves it either:
// the session owns its lifetime.
expect(approvalInbox.verifyStillAnswerable(item.id)).toBe(true);
approvalInbox.resolveIfDialogGone(SESSION.id);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(item.id);
});
it('clears by resolving the item it opened, and only that one', () => {
const resolved: string[] = [];
const prev = approvalInbox.onResolved;
approvalInbox.onResolved = (info) => resolved.push(`${info.id}:${info.resolution}`);
try {
const itemId = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
expect(resolved).toContain(`${itemId}:resolved_in_terminal`);
// An item it did not open (here a newer one) is left alone.
const newer = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
clearScreenBlocked(SESSION.id, null);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
} finally {
approvalInbox.onResolved = prev;
}
});
it('raises nothing for a session whose CLI reports blocked through hooks', () => {
const d = deps();
expect(raiseScreenBlocked({ ...SESSION, mode: 'claude' }, d, null)).toBeNull();
expect(d.broadcast).not.toHaveBeenCalled();
expect(d.sendPushNotifications).not.toHaveBeenCalled();
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
});
});