mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user