mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
110 lines
5.2 KiB
TypeScript
110 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();
|
||
|
|
});
|
||
|
|
});
|