Files
hapi/cli/src/codex/shared/root.test.ts
T
878ca2058c feat(codex): expose Approve for me as a permission mode (#2016) (#2090)
* feat(codex): expose Approve for me as a permission mode (#2016)

Add autoReview to the Codex permission catalog. It keeps the
workspace-write sandbox with on-request approvals and routes escalations
to Codex's native reviewer (approvals_reviewer = "auto_review"), the same
config as `hapi codex --approve-for-me`. It works at launch and mid-session
via thread/settings/update; explicit modes now reset the reviewer to user,
and sessions report autoReview from the native reviewer setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): expect Auto-review in the Codex permission lists (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(modes): label autoReview as Auto, matching Claude's auto mode (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:47 +08:00

421 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentState, Metadata } from '@/api/types';
import type { SessionBootstrapResult } from '@/agent/sessionFactory';
import { SharedCodexRoot, type RootHost } from './root';
import { codexPlanProposalId } from './plan';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
type NativeTurn = { id: string; status: string; items: unknown[] };
vi.mock('../codexAppServerClient', () => ({
CodexAppServerClient: class {
initialized = false;
thread = { id: 'thread', turns: [] as NativeTurn[] };
settings: Record<string, unknown> = { model: 'mock', collaborationMode: { mode: 'default' } };
queue: Array<{ id: string; clientUserMessageId: unknown; input: unknown }> = [];
notify?: (method: string, params: unknown) => void;
abandoned?: () => void;
setNotificationHandler(handler: typeof this.notify) { this.notify = handler; }
setTransportAbandonedHandler(handler: (() => void) | null) { this.abandoned = handler ?? undefined; }
setServerRequestHandler() {}
async connect() {}
async initialize() { this.initialized = true; }
isInitialized() { return this.initialized; }
async disconnect() { this.initialized = false; }
async request(method: string, params: Record<string, unknown> = {}) {
if (method === 'thread/read' || method === 'thread/resume') return { ...this.settings, thread: structuredClone(this.thread) };
if (method === 'thread/list') return { data: [] };
if (method === 'thread/queue/list') return { data: this.queue };
if (method === 'thread/settings/update') {
this.settings = { ...this.settings, ...params };
this.notify?.('thread/settings/updated', { threadId: 'thread', threadSettings: this.settings });
return {};
}
if (method === 'thread/queue/add') {
const entry = { id: `queued-${this.queue.length}`, clientUserMessageId: params.clientUserMessageId, input: params.input };
this.queue.push(entry);
return { queuedSubmission: entry };
}
throw new Error(`Unexpected request: ${method}`);
}
},
isIndeterminateError: () => false
}));
vi.mock('../utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => ({
mcpServers: {}, server: { stop() {} }
}) }));
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
try { for (const cleanup of cleanups.splice(0)) await cleanup(); }
finally { vi.useRealTimers(); }
});
async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end'] }) {
const directory = await mkdtemp('/tmp/hapi-shared-root-');
let state: AgentState = { steeringActive: true };
let metadata: Metadata = { path: directory, host: 'test', flavor: 'codex' };
let reconnect: (() => void) | null = null;
const updateState = vi.fn((fn: (value: AgentState) => AgentState) => { state = fn(state); });
const rpc = new Map<string, (raw: unknown) => Promise<unknown>>();
const send = vi.fn();
const consumed = vi.fn();
const uncertain = vi.fn();
const hubArchivedListeners: Array<() => void> = [];
const session = {
sessionId: 'sid', getMetadata: () => metadata,
hubArchived: opts?.hubArchived ?? false,
updateMetadata: (fn: (value: Metadata) => Metadata) => { metadata = fn(metadata); },
updateAgentState: updateState, keepAlive() {},
onUserMessage() {}, onCancelQueuedMessage() {}, onRetryQueuedMessage() {},
onReconnect: (fn: (() => void) | null) => { reconnect = fn; },
on(event: string, listener: () => void) {
if (event === 'hub-archived') hubArchivedListeners.push(listener);
},
rpcHandlerManager: { registerHandler: (name: string, handler: (raw: unknown) => Promise<unknown>) => rpc.set(name, handler) },
sendSessionEvent() {}, sendAgentMessage: send, emitSessionReady() {},
sendUserMessage() {}, emitMessagesConsumed: consumed, emitSteerIndeterminate: uncertain, syncNativeQueuedMessage() {},
sendSessionDeath() {}, async flush() {}, close() {}
} as unknown as ApiSessionClient;
const end = opts?.end ?? (async () => { throw new Error('Unexpected root archive'); });
const root = new SharedCodexRoot({ session, workingDirectory: directory } as SessionBootstrapResult, {
directory, generation: 'test', endpoint: 'mock', settingsFor: () => undefined,
create: async () => { throw new Error('Unexpected root creation'); },
end
} satisfies RootHost);
cleanups.push(async () => { await root.close(false); await rm(directory, { recursive: true, force: true }); });
await root.prepare();
await root.bind('thread', { model: 'mock', thread: { turns: [] } }, false);
const native = root.client as unknown as {
initialized: boolean;
thread: { id: string; turns: NativeTurn[] };
queue: Array<{ id: string; clientUserMessageId: string; input: unknown }>;
notify(method: string, params: unknown): void;
abandoned(): void;
};
return {
root, native, rpc, send, consumed, uncertain, metadata: () => metadata, state: () => state, updateState,
drain: () => (root as unknown as { notifications: Promise<void> }).notifications,
reconnect: () => reconnect?.(),
emitHubArchived: () => { for (const listener of hubArchivedListeners) listener(); },
hubArchivedListenerCount: () => hubArchivedListeners.length,
end,
};
}
async function startCompaction(f: Awaited<ReturnType<typeof fixture>>, id = 'input', turnId = 'compacting-turn') {
f.native.queue = [{ id: `native-${id}`, clientUserMessageId: id, input: [{ type: 'text', text: id }] }];
await f.root.refresh();
f.native.queue = [];
// Native removes input before announcing the turn and before recording userMessage.
f.native.notify('thread/queue/changed', { threadId: 'thread' });
await vi.waitFor(() => expect(f.uncertain).toHaveBeenCalledWith(expect.arrayContaining([id])));
const turn = { id: turnId, status: 'inProgress', items: [] };
f.native.thread.turns.push(turn);
f.native.notify('turn/started', { threadId: 'thread', turn });
f.native.notify('item/started', { threadId: 'thread', turnId, item: { id: 'compact', type: 'contextCompaction' } });
return turn;
}
describe('compaction delivery confirmation', () => {
it('does not turn uncertainty discovered by a cold snapshot into live compaction evidence', async () => {
const f = await fixture();
f.native.queue = [{ id: 'old-native', clientUserMessageId: 'old', input: [] }];
await f.root.refresh();
f.native.queue = [];
await f.root.refresh();
expect(f.uncertain).toHaveBeenCalledWith(['old']);
f.native.thread.turns.push({ id: 'later', status: 'inProgress', items: [] });
f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'later' } });
f.native.notify('item/started', { threadId: 'thread', turnId: 'later', item: { id: 'c', type: 'contextCompaction' } });
await f.drain();
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] });
});
it('reports only freshly dequeued input as waiting, without acknowledging or replaying it', async () => {
const f = await fixture();
const request = vi.spyOn(f.root.client, 'request');
const turn = await startCompaction(f);
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['input'] }));
expect(f.consumed).not.toHaveBeenCalled();
expect(request.mock.calls.some(([method]) => method === 'thread/queue/add')).toBe(false);
f.native.notify('item/started', { threadId: 'thread', turnId: turn.id,
item: { id: 'user', type: 'userMessage', clientId: 'input', content: [] } });
await vi.waitFor(() => expect(f.consumed).toHaveBeenCalledWith(['input'], { steered: undefined }));
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] });
});
it.each(['completed', 'interrupted', 'failed'])('restores uncertainty when the turn is %s without consumption', async status => {
const f = await fixture();
const turn = await startCompaction(f);
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['input'] }));
turn.status = status;
f.native.notify('turn/completed', { threadId: 'thread', turn });
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] }));
expect(f.consumed).not.toHaveBeenCalled();
await startCompaction(f, 'next', 'next-turn');
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['next'] }));
});
it('withdraws waiting at compaction completion and ignores child and stale turn events', async () => {
const f = await fixture();
const turn = await startCompaction(f);
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['input'] }));
for (const [threadId, turnId] of [['child', turn.id], ['thread', 'stale']]) {
f.native.notify('item/completed', { threadId, turnId, item: { id: 'other', type: 'contextCompaction' } });
}
await f.drain();
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['input'] });
f.native.notify('item/completed', { threadId: 'thread', turnId: turn.id, item: { id: 'compact', type: 'contextCompaction' } });
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] }));
expect(f.consumed).not.toHaveBeenCalled();
});
it('does not keep waiting after native disconnect or stopping', async () => {
const f = await fixture();
await startCompaction(f);
await vi.waitFor(() => expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: ['input'] }));
f.native.initialized = false;
f.native.abandoned();
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] });
await vi.waitFor(() => expect(f.native.initialized).toBe(true));
await f.root.refresh();
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] });
f.root.stopAccepting();
expect(f.state()).toMatchObject({ codexCompactionPendingMessageIds: [] });
});
});
async function completePlan(f: Awaited<ReturnType<typeof fixture>>, status = 'completed') {
await f.root.applySettings({ collaborationMode: 'plan' });
const turn = { id: 'plan-turn', status: 'inProgress', items: [{ id: 'plan-item', type: 'plan', text: '# Implement me' }] };
f.native.thread.turns.push(turn);
f.native.notify('turn/started', { threadId: 'thread', turn: { id: turn.id } });
f.native.notify('item/completed', { threadId: 'thread', turnId: turn.id, item: turn.items[0] });
expect(f.state().codexPlanProposalId).toBeNull();
turn.status = status;
f.native.notify('turn/completed', { threadId: 'thread', turn: { id: turn.id, status } });
await vi.waitFor(() => expect(f.send).toHaveBeenCalledWith(expect.objectContaining({ name: 'ExitPlanMode' }), expect.any(String)));
return codexPlanProposalId('thread', turn.id, 'plan-item');
}
describe('shared permission modes', () => {
it('switches native auto-review on and back to the human reviewer', async () => {
const f = await fixture();
expect((await f.root.applySettings({ permissionMode: 'autoReview' })).applied.permissionMode).toBe('autoReview');
expect(f.root.client).toMatchObject({ settings: { approvalPolicy: 'on-request', approvalsReviewer: 'auto_review', sandboxPolicy: { type: 'workspaceWrite' } } });
expect((await f.root.applySettings({ permissionMode: 'default' })).applied.permissionMode).toBe('default');
expect(f.root.client).toMatchObject({ settings: { approvalsReviewer: 'user' } });
});
});
describe('shared plan actions', () => {
it('applies remote change_title as metadata.name then lets native terminal rename win', async () => {
const f = await fixture();
const item = { id: 'title', type: 'mcpToolCall', server: 'hapi', tool: 'change_title',
arguments: { title: 'Remote title' }, status: 'completed', result: { content: [], isError: false } };
f.native.notify('item/completed', { threadId: 'thread', turnId: 'turn', item });
await vi.waitFor(() => expect(f.metadata().name).toBe('Remote title'));
f.native.notify('thread/name/updated', { threadId: 'thread', threadName: 'Terminal title' });
await vi.waitFor(() => expect(f.metadata().name).toBe('Terminal title'));
await f.root.refresh();
expect(f.metadata().name).toBe('Terminal title');
});
it('preserves content while native turns, mode changes and disconnects withdraw controls', async () => {
const f = await fixture();
const id = await completePlan(f);
expect(f.state().codexPlanProposalId).toBe(id);
expect(f.state().requests).toEqual({});
await f.root.applySettings({ collaborationMode: 'default' });
expect(f.state().codexPlanProposalId).toBeNull();
await f.root.applySettings({ collaborationMode: 'plan' });
expect(f.state().codexPlanProposalId).toBe(id);
f.native.initialized = false; f.native.abandoned();
expect(f.state().codexPlanProposalId).toBeNull();
await vi.waitFor(() => expect(f.state().codexPlanProposalId).toBe(id));
f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'new' } });
f.native.notify('turn/completed', { threadId: 'thread', turn: { id: 'plan-turn', status: 'completed' } });
expect(f.state().codexPlanProposalId).toBeNull();
expect(f.send.mock.calls.some(([message]) => message.input?.plan === '# Implement me')).toBe(true);
});
it.each(['failed', 'interrupted'])('does not offer a proposal from a %s turn', async status => {
const f = await fixture();
await completePlan(f, status);
await f.root.refresh();
expect(f.state().codexPlanProposalId).toBeNull();
});
it('uses only the latest root turn when replaying history', async () => {
const f = await fixture();
const id = await completePlan(f);
f.reconnect();
await f.root.refresh();
await vi.waitFor(() => expect(f.state().codexPlanProposalId).toBe(id));
f.native.thread.turns.push({ id: 'new', status: 'completed', items: [] });
await f.root.refresh();
expect(f.state().codexPlanProposalId).toBeNull();
f.native.notify('item/completed', { threadId: 'child', turnId: 'child-turn', item: { id: 'p', type: 'plan', text: 'child' } });
expect(f.state().codexPlanProposalId).toBeNull();
});
it('switches mode and submits once across repeated Web actions and lost replies', async () => {
const f = await fixture();
await f.root.activate();
const id = await completePlan(f);
const action = () => f.rpc.get(RPC_METHODS.ImplementCodexPlan)!({ planId: id });
const request = vi.spyOn(f.root.client, 'request');
expect(await Promise.all([action(), action()])).toEqual([{ ok: true }, { ok: true }]);
f.reconnect();
expect(await action()).toEqual({ ok: true });
expect(request.mock.calls.filter(([method]) => method === 'thread/queue/add')).toHaveLength(1);
const settingsIndex = request.mock.calls.findIndex(([method]) => method === 'thread/settings/update');
const queueIndex = request.mock.calls.findIndex(([method]) => method === 'thread/queue/add');
expect(settingsIndex).toBeLessThan(queueIndex);
expect(request.mock.calls[settingsIndex][1]).toMatchObject({ collaborationMode: { mode: 'default' } });
expect(f.native.queue[0]).toMatchObject({ input: [{ type: 'text', text: 'Implement the plan.' }] });
expect(f.state().codexPlanProposalId).toBeNull();
});
it('does not let a slow history snapshot resurrect a plan after native continuation', async () => {
const f = await fixture();
await completePlan(f);
const request = f.root.client.request.bind(f.root.client);
let release!: () => void;
const blocked = new Promise<void>(resolve => { release = resolve; });
let reading!: () => void;
const started = new Promise<void>(resolve => { reading = resolve; });
vi.spyOn(f.root.client, 'request').mockImplementation(async (method, params) => {
const result = await request(method, params);
if (method === 'thread/read' && (params as { includeTurns?: boolean }).includeTurns) {
reading(); await blocked;
}
return result;
});
const refresh = f.root.refresh();
await started;
f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'terminal-continued' } });
release(); await refresh;
expect(f.state().codexPlanProposalId).toBeNull();
});
it('does not change mode when native input appears during the action preflight', async () => {
const f = await fixture();
await f.root.activate();
const id = await completePlan(f);
const request = f.root.client.request.bind(f.root.client);
const spy = vi.spyOn(f.root.client, 'request').mockImplementation(async (method, params) => {
const result = await request(method, params);
if (method === 'thread/queue/list') f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'terminal' } });
return result;
});
expect(await f.rpc.get(RPC_METHODS.ImplementCodexPlan)!({ planId: id })).toMatchObject({ ok: false, code: 'stale_plan' });
expect(spy.mock.calls.some(([method]) => method === 'thread/settings/update')).toBe(false);
expect(f.native.queue).toHaveLength(0);
});
it('rejects stale proposals and native activity arriving during the mode switch', async () => {
const f = await fixture();
await f.root.activate();
const id = await completePlan(f);
const action = (planId = id) => f.rpc.get(RPC_METHODS.ImplementCodexPlan)!({ planId });
expect(await action('old')).toMatchObject({ ok: false, code: 'stale_plan' });
const request = f.root.client.request.bind(f.root.client);
vi.spyOn(f.root.client, 'request').mockImplementation(async (method, params) => {
const result = await request(method, params);
if (method === 'thread/settings/update') f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'terminal' } });
return result;
});
expect(await action()).toMatchObject({ ok: false, code: 'stale_plan' });
expect(f.native.queue).toHaveLength(0);
});
it('does not resend an implementation with an unknown queue outcome', async () => {
const f = await fixture();
await f.root.activate();
const id = await completePlan(f);
const request = f.root.client.request.bind(f.root.client);
const spy = vi.spyOn(f.root.client, 'request').mockImplementation(async (method, params) => {
const result = await request(method, params);
// An invalid response schema leaves delivery indeterminate even after acceptance.
return method === 'thread/queue/add' ? {} : result;
});
const action = () => f.rpc.get(RPC_METHODS.ImplementCodexPlan)!({ planId: id });
expect(await action()).toMatchObject({ ok: false, code: 'indeterminate' });
f.native.queue = []; // Absence is not proof of cancellation or delivery.
expect(await action()).toMatchObject({ ok: false, code: 'indeterminate' });
expect(spy.mock.calls.filter(([method]) => method === 'thread/queue/add')).toHaveLength(1);
});
});
describe('shared steering availability', () => {
it('keeps idle sessions online without polling usage or publishing agent-state updates', async () => {
const f = await fixture();
const requests = vi.spyOn(f.root.client, 'request');
const heartbeat = vi.spyOn(f.root.session, 'keepAlive');
const updates = f.updateState.mock.calls.length;
vi.useFakeTimers();
await f.root.activate();
await vi.advanceTimersByTimeAsync(5 * 60_000);
expect(heartbeat).toHaveBeenCalled();
expect(requests).not.toHaveBeenCalled();
expect(f.updateState).toHaveBeenCalledTimes(updates);
});
it('publishes root turn transitions, ignores child turns, and clears on shutdown', async () => {
const f = await fixture();
expect(f.state().steeringActive).toBe(false);
f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'turn' } });
expect(f.state().steeringActive).toBe(true);
f.native.notify('turn/completed', { threadId: 'thread', turn: { id: 'old-turn' } });
expect(f.state().steeringActive).toBe(true);
f.native.notify('turn/completed', { threadId: 'thread', turn: { id: 'turn', status: 'completed' } });
expect(f.state().steeringActive).toBe(false);
f.native.notify('turn/started', { threadId: 'child', turn: { id: 'child-turn' } });
expect(f.state().steeringActive).toBe(false);
f.native.notify('turn/started', { threadId: 'thread', turn: { id: 'next' } });
f.root.stopAccepting();
expect(f.state().steeringActive).toBe(false);
});
it('reconciles native and Hub reconnects without publishing on every refresh', async () => {
const f = await fixture();
const updates = f.updateState.mock.calls.length;
await f.root.refresh(); await f.root.refresh();
expect(f.updateState).toHaveBeenCalledTimes(updates);
f.native.thread.turns = [{ id: 'busy', status: 'inProgress', items: [] }];
f.reconnect();
await vi.waitFor(() => expect(f.state().steeringActive).toBe(true));
f.native.initialized = false; f.native.abandoned();
expect(f.state().steeringActive).toBe(false);
await vi.waitFor(() => expect(f.state().steeringActive).toBe(true));
f.native.thread.turns = [{ id: 'busy', status: 'completed', items: [] }];
f.reconnect();
await vi.waitFor(() => expect(f.state().steeringActive).toBe(false));
});
it('ends the shared root on hub-archived metadata (#1911 C2 / AC6)', async () => {
const end = vi.fn<RootHost['end']>(async () => {});
const f = await fixture({ end });
await f.root.activate();
expect(f.hubArchivedListenerCount()).toBe(1);
f.emitHubArchived();
await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1));
expect(end.mock.calls[0]?.[0]).toBe(f.root);
});
it('ends when hubArchived was latched before activate (production order, #1911 AC6)', async () => {
// Bootstrap may refuse CAS / noteHubArchived before Codex bind+activate
// registers controls — same late-listener miss as other flavors.
const end = vi.fn<RootHost['end']>(async () => {});
const f = await fixture({ hubArchived: true, end });
await f.root.activate();
await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1));
});
});