mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(codex): use hook to obtain the session id and transcript path
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
const harness = {
|
||||
launches: [] as Array<Record<string, unknown>>,
|
||||
sessionScannerCalls: [] as Array<Record<string, unknown>>,
|
||||
scannerFailureMessage: 'No Codex session found within 120000ms for cwd c:\\workspace\\project; refusing fallback.'
|
||||
}));
|
||||
sessionHookHandlers: [] as Array<(sessionId: string, data: Record<string, unknown>) => void>,
|
||||
runBarrier: null as Promise<void> | null
|
||||
};
|
||||
|
||||
vi.mock('./codexLocal', () => ({
|
||||
codexLocal: async (opts: Record<string, unknown>) => {
|
||||
@@ -22,17 +26,13 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('./utils/codexSessionScanner', () => ({
|
||||
createCodexSessionScanner: async (opts: {
|
||||
onSessionMatchFailed?: (message: string) => void;
|
||||
}) => {
|
||||
harness.sessionScannerCalls.push(opts as Record<string, unknown>);
|
||||
vi.mock('@/claude/utils/startHookServer', () => ({
|
||||
startHookServer: async (opts: { onSessionHook: (sessionId: string, data: Record<string, unknown>) => void }) => {
|
||||
harness.sessionHookHandlers.push(opts.onSessionHook);
|
||||
return {
|
||||
cleanup: async () => {},
|
||||
onNewSession: () => {},
|
||||
triggerFailure: () => {
|
||||
opts.onSessionMatchFailed?.(harness.scannerFailureMessage);
|
||||
}
|
||||
port: 4242,
|
||||
token: 'hook-token',
|
||||
stop: () => {}
|
||||
};
|
||||
}
|
||||
}));
|
||||
@@ -47,6 +47,9 @@ vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({
|
||||
|
||||
async run(): Promise<'exit'> {
|
||||
await this.opts.launch(new AbortController().signal);
|
||||
if (harness.runBarrier) {
|
||||
await harness.runBarrier;
|
||||
}
|
||||
return 'exit';
|
||||
}
|
||||
}
|
||||
@@ -62,13 +65,27 @@ function createQueueStub() {
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree') {
|
||||
function createSessionStub(
|
||||
permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo',
|
||||
codexArgs?: string[],
|
||||
path = '/tmp/worktree',
|
||||
initialTranscriptPath: string | null = null
|
||||
) {
|
||||
const sessionEvents: Array<{ type: string; message?: string }> = [];
|
||||
const agentMessages: unknown[] = [];
|
||||
let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let transcriptPath: string | null = initialTranscriptPath;
|
||||
const transcriptPathCallbacks: Array<(path: string) => void> = [];
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: null,
|
||||
get sessionId() {
|
||||
return sessionId;
|
||||
},
|
||||
get transcriptPath() {
|
||||
return transcriptPath;
|
||||
},
|
||||
path,
|
||||
startedBy: 'terminal' as const,
|
||||
startingMode: 'local' as const,
|
||||
@@ -80,7 +97,27 @@ function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo'
|
||||
},
|
||||
getPermissionMode: () => permissionMode,
|
||||
getModelReasoningEffort: () => null,
|
||||
onSessionFound: () => {},
|
||||
onSessionFound: (value: string) => {
|
||||
sessionId = value;
|
||||
},
|
||||
onTranscriptPathFound: (pathValue: string) => {
|
||||
transcriptPath = pathValue;
|
||||
for (const callback of transcriptPathCallbacks) {
|
||||
callback(pathValue);
|
||||
}
|
||||
},
|
||||
addTranscriptPathCallback: (callback: (path: string) => void) => {
|
||||
transcriptPathCallbacks.push(callback);
|
||||
},
|
||||
removeTranscriptPathCallback: (callback: (path: string) => void) => {
|
||||
const index = transcriptPathCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
transcriptPathCallbacks.splice(index, 1);
|
||||
}
|
||||
},
|
||||
resetTranscriptPath: () => {
|
||||
transcriptPath = null;
|
||||
},
|
||||
sendSessionEvent: (event: { type: string; message?: string }) => {
|
||||
sessionEvents.push(event);
|
||||
},
|
||||
@@ -88,18 +125,38 @@ function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo'
|
||||
localLaunchFailure = { message, exitReason };
|
||||
},
|
||||
sendUserMessage: () => {},
|
||||
sendAgentMessage: () => {},
|
||||
sendAgentMessage: (message: unknown) => {
|
||||
agentMessages.push(message);
|
||||
},
|
||||
queue: createQueueStub()
|
||||
},
|
||||
sessionEvents,
|
||||
agentMessages,
|
||||
getLocalLaunchFailure: () => localLaunchFailure
|
||||
};
|
||||
}
|
||||
|
||||
describe('codexLocalLauncher', () => {
|
||||
let tempDir = '';
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `codex-local-launcher-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
harness.launches = [];
|
||||
harness.sessionScannerCalls = [];
|
||||
harness.sessionHookHandlers = [];
|
||||
harness.runBarrier = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(tempDir)) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rebuilds approval and sandbox args from yolo mode', async () => {
|
||||
@@ -175,16 +232,151 @@ describe('codexLocalLauncher', () => {
|
||||
it('warns on session match failure without aborting local Codex launch', async () => {
|
||||
const { session, sessionEvents, getLocalLaunchFailure } = createSessionStub('default', undefined, 'c:\\workspace\\project');
|
||||
|
||||
await codexLocalLauncher(session as never);
|
||||
vi.useFakeTimers();
|
||||
const launcherPromise = codexLocalLauncher(session as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
await launcherPromise;
|
||||
|
||||
const scannerCall = harness.sessionScannerCalls[0] as { onSessionMatchFailed?: (message: string) => void } | undefined;
|
||||
scannerCall?.onSessionMatchFailed?.(harness.scannerFailureMessage);
|
||||
|
||||
expect(harness.launches).toHaveLength(1);
|
||||
expect(harness.launches.length).toBeGreaterThan(0);
|
||||
expect(getLocalLaunchFailure()).toBeNull();
|
||||
expect(sessionEvents).toContainEqual({
|
||||
type: 'message',
|
||||
message: `${harness.scannerFailureMessage} Keeping local Codex running; remote transcript sync may be unavailable for this launch.`
|
||||
message: 'No Codex SessionStart hook transcript path received within 10000ms. Keeping local Codex running; remote transcript sync may be unavailable for this launch.'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reuse a stale transcript path from a previous launch', async () => {
|
||||
const staleTranscriptPath = join(tempDir, 'stale-transcript.jsonl');
|
||||
const { session, sessionEvents } = createSessionStub('default', undefined, '/tmp/worktree', staleTranscriptPath);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const launcherPromise = codexLocalLauncher(session as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(session.transcriptPath).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
await launcherPromise;
|
||||
|
||||
expect(sessionEvents).toContainEqual({
|
||||
type: 'message',
|
||||
message: 'No Codex SessionStart hook transcript path received within 10000ms. Keeping local Codex running; remote transcript sync may be unavailable for this launch.'
|
||||
});
|
||||
});
|
||||
|
||||
it('passes SessionStart hook config into local Codex launch', async () => {
|
||||
const { session } = createSessionStub('default');
|
||||
|
||||
await codexLocalLauncher(session as never);
|
||||
|
||||
expect(harness.launches).toHaveLength(1);
|
||||
expect(harness.launches[0]?.sessionHook).toEqual({
|
||||
port: 4242,
|
||||
token: 'hook-token'
|
||||
});
|
||||
});
|
||||
|
||||
it('creates scanner only after transcript path arrives from SessionStart hook', async () => {
|
||||
const transcriptPath = join(tempDir, 'codex-transcript.jsonl');
|
||||
const { session, agentMessages } = createSessionStub('default');
|
||||
let releaseRunBarrier: (() => void) | undefined;
|
||||
harness.runBarrier = new Promise((resolve) => {
|
||||
releaseRunBarrier = resolve;
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-1' } }) + '\n'
|
||||
);
|
||||
|
||||
const launcherPromise = codexLocalLauncher(session as never);
|
||||
await wait(50);
|
||||
expect(session.transcriptPath).toBeNull();
|
||||
expect(agentMessages).toHaveLength(0);
|
||||
|
||||
harness.sessionHookHandlers[0]?.('codex-thread-1', {
|
||||
transcript_path: transcriptPath
|
||||
});
|
||||
await wait(100);
|
||||
|
||||
await appendFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello from transcript' } }) + '\n'
|
||||
);
|
||||
|
||||
await wait(700);
|
||||
if (releaseRunBarrier) {
|
||||
releaseRunBarrier();
|
||||
}
|
||||
await launcherPromise;
|
||||
|
||||
expect(session.transcriptPath).toBe(transcriptPath);
|
||||
expect(agentMessages).toContainEqual({
|
||||
type: 'message',
|
||||
message: 'hello from transcript',
|
||||
id: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leave transcript scanning alive after launcher teardown', async () => {
|
||||
const transcriptPath = join(tempDir, 'teardown-race-transcript.jsonl');
|
||||
const { session, agentMessages } = createSessionStub('default');
|
||||
let releaseRunBarrier: (() => void) | undefined;
|
||||
harness.runBarrier = new Promise((resolve) => {
|
||||
releaseRunBarrier = resolve;
|
||||
});
|
||||
|
||||
const oldLines = Array.from({ length: 20_000 }, (_, index) =>
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: `old-${index}` } })
|
||||
).join('\n');
|
||||
await writeFile(transcriptPath, oldLines + '\n');
|
||||
|
||||
const launcherPromise = codexLocalLauncher(session as never);
|
||||
await wait(50);
|
||||
|
||||
harness.sessionHookHandlers[0]?.('codex-thread-race', {
|
||||
transcript_path: transcriptPath
|
||||
});
|
||||
if (releaseRunBarrier) {
|
||||
releaseRunBarrier();
|
||||
}
|
||||
await launcherPromise;
|
||||
|
||||
await appendFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'post-teardown' } }) + '\n'
|
||||
);
|
||||
await wait(2300);
|
||||
|
||||
expect(agentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores late SessionStart hooks after shutdown begins', async () => {
|
||||
const staleTranscriptPath = join(tempDir, 'late-hook-transcript.jsonl');
|
||||
const { session } = createSessionStub('default');
|
||||
let releaseRunBarrier: (() => void) | undefined;
|
||||
harness.runBarrier = new Promise((resolve) => {
|
||||
releaseRunBarrier = resolve;
|
||||
});
|
||||
|
||||
const launcherPromise = codexLocalLauncher(session as never);
|
||||
await wait(50);
|
||||
|
||||
if (releaseRunBarrier) {
|
||||
releaseRunBarrier();
|
||||
}
|
||||
await launcherPromise;
|
||||
|
||||
harness.sessionHookHandlers[0]?.('late-local-thread', {
|
||||
transcript_path: staleTranscriptPath
|
||||
});
|
||||
|
||||
expect(session.sessionId).toBeNull();
|
||||
expect(session.transcriptPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user