feat(codex): share sessions across terminal and web

Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
weishu
2026-09-12 10:59:17 +08:00
parent 25af3f8e13
commit 0c4abcb3d1
111 changed files with 4684 additions and 960 deletions
+100
View File
@@ -0,0 +1,100 @@
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';
vi.mock('../codexAppServerClient', () => ({
CodexAppServerClient: class {
initialized = false;
thread = { id: 'thread', turns: [] as Array<{ id: string; status: string; items: 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) {
if (method === 'thread/read' || method === 'thread/resume') return { model: 'mock', thread: this.thread };
if (method === 'thread/list' || method === 'thread/queue/list') return { data: [] };
throw new Error(`Unexpected request: ${method}`);
}
},
isIndeterminateError: () => false
}));
vi.mock('../utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => ({
mcpServers: {}, server: { stop() {} }
}) }));
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); });
async function fixture() {
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 session = {
sessionId: 'sid', getMetadata: () => metadata,
updateMetadata: (fn: (value: Metadata) => Metadata) => { metadata = fn(metadata); },
updateAgentState: updateState, keepAlive() {},
onUserMessage() {}, onCancelQueuedMessage() {}, onRetryQueuedMessage() {},
onReconnect: (fn: (() => void) | null) => { reconnect = fn; },
rpcHandlerManager: { registerHandler() {} }, sendSessionEvent() {}, sendAgentMessage() {},
sendSessionDeath() {}, async flush() {}, close() {}
} as unknown as ApiSessionClient;
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: async () => { throw new Error('Unexpected root archive'); }
} 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: Array<{ id: string; status: string; items: unknown[] }> };
notify(method: string, params: unknown): void;
abandoned(): void;
};
return { root, native, state: () => state, updateState, reconnect: () => reconnect?.() };
}
describe('shared steering availability', () => {
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));
});
});