feat(codex): use hook to obtain the session id and transcript path

This commit is contained in:
weishu
2026-04-24 10:52:38 +08:00
parent 82703b85fb
commit 8e54bbeb53
8 changed files with 610 additions and 762 deletions
+218 -26
View File
@@ -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();
});
});