mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* 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>
421 lines
23 KiB
TypeScript
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));
|
|
});
|
|
});
|