mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
1400 lines
50 KiB
TypeScript
1400 lines
50 KiB
TypeScript
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 = {
|
|
launches: [] as Array<Record<string, unknown>>,
|
|
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>) => {
|
|
harness.launches.push(opts);
|
|
}
|
|
}));
|
|
|
|
vi.mock('./utils/buildHapiMcpBridge', () => ({
|
|
buildHapiMcpBridge: async () => ({
|
|
server: {
|
|
url: 'http://localhost:0',
|
|
stop: () => {}
|
|
},
|
|
mcpServers: {}
|
|
})
|
|
}));
|
|
|
|
vi.mock('@/claude/utils/startHookServer', () => ({
|
|
startHookServer: async (opts: { onSessionHook: (sessionId: string, data: Record<string, unknown>) => void }) => {
|
|
harness.sessionHookHandlers.push(opts.onSessionHook);
|
|
return {
|
|
port: 4242,
|
|
token: 'hook-token',
|
|
stop: () => {}
|
|
};
|
|
}
|
|
}));
|
|
|
|
vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({
|
|
BaseLocalLauncher: class {
|
|
readonly control = {
|
|
requestExit: () => {}
|
|
};
|
|
|
|
constructor(private readonly opts: { launch: (signal: AbortSignal) => Promise<void> }) {}
|
|
|
|
async run(): Promise<'exit'> {
|
|
await this.opts.launch(new AbortController().signal);
|
|
if (harness.runBarrier) {
|
|
await harness.runBarrier;
|
|
}
|
|
return 'exit';
|
|
}
|
|
}
|
|
}));
|
|
|
|
import { codexLocalLauncher } from './codexLocalLauncher';
|
|
|
|
function createQueueStub() {
|
|
return {
|
|
size: () => 0,
|
|
reset: () => {},
|
|
setOnMessage: () => {}
|
|
};
|
|
}
|
|
|
|
function createSessionStub(
|
|
permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo',
|
|
codexArgs?: string[],
|
|
path = '/tmp/worktree',
|
|
initialTranscriptPath: string | null = null,
|
|
replayTranscriptHistoryOnStart = false,
|
|
pendingClient = false
|
|
) {
|
|
const sessionEvents: Array<{ type: string; message?: string }> = [];
|
|
const userMessages: string[] = [];
|
|
const agentMessages: unknown[] = [];
|
|
let userActivityCount = 0;
|
|
let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null;
|
|
let sessionId: string | null = null;
|
|
let transcriptPath: string | null = initialTranscriptPath;
|
|
let transcriptHistoryReplayPending = replayTranscriptHistoryOnStart;
|
|
let modelReasoningEffort: string | null = null;
|
|
const modelReasoningEffortUpdates: Array<string | null> = [];
|
|
const transcriptPathCallbacks: Array<(path: string) => void> = [];
|
|
|
|
return {
|
|
session: {
|
|
get sessionId() {
|
|
return sessionId;
|
|
},
|
|
get transcriptPath() {
|
|
return transcriptPath;
|
|
},
|
|
path,
|
|
startedBy: 'terminal' as const,
|
|
startingMode: 'local' as const,
|
|
codexArgs,
|
|
shouldReplayTranscriptHistory: () => transcriptHistoryReplayPending,
|
|
markTranscriptHistoryReplayConsumed: () => {
|
|
transcriptHistoryReplayPending = false;
|
|
},
|
|
client: {
|
|
isPending: () => pendingClient,
|
|
rpcHandlerManager: {
|
|
registerHandler: () => {}
|
|
}
|
|
},
|
|
getPermissionMode: () => permissionMode,
|
|
getModelReasoningEffort: () => modelReasoningEffort,
|
|
setModelReasoningEffort: (effort: string | null) => {
|
|
modelReasoningEffort = effort;
|
|
modelReasoningEffortUpdates.push(effort);
|
|
},
|
|
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);
|
|
},
|
|
recordLocalLaunchFailure: (message: string, exitReason: 'switch' | 'exit') => {
|
|
localLaunchFailure = { message, exitReason };
|
|
},
|
|
sendUserMessage: (message: string) => {
|
|
userMessages.push(message);
|
|
},
|
|
notifyUserActivity: () => {
|
|
userActivityCount += 1;
|
|
},
|
|
sendAgentMessage: (message: unknown) => {
|
|
agentMessages.push(message);
|
|
},
|
|
queue: createQueueStub()
|
|
},
|
|
sessionEvents,
|
|
userMessages,
|
|
agentMessages,
|
|
getUserActivityCount: () => userActivityCount,
|
|
getLocalLaunchFailure: () => localLaunchFailure,
|
|
getModelReasoningEffort: () => modelReasoningEffort,
|
|
getModelReasoningEffortUpdates: () => modelReasoningEffortUpdates
|
|
};
|
|
}
|
|
|
|
describe('codexLocalLauncher', () => {
|
|
let tempDir = '';
|
|
|
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const writeTranscriptMeta = async (fileName: string, sessionId: string): Promise<string> => {
|
|
const transcriptPath = join(tempDir, fileName);
|
|
await writeFile(
|
|
transcriptPath,
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: {
|
|
id: sessionId
|
|
}
|
|
}) + '\n'
|
|
);
|
|
return transcriptPath;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
tempDir = join(tmpdir(), `codex-local-launcher-${Date.now()}`);
|
|
await mkdir(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
harness.launches = [];
|
|
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 () => {
|
|
const { session } = createSessionStub('yolo', [
|
|
'--sandbox',
|
|
'read-only',
|
|
'--ask-for-approval',
|
|
'untrusted',
|
|
'--model',
|
|
'o3',
|
|
'--full-auto'
|
|
]);
|
|
|
|
await codexLocalLauncher(session as never);
|
|
|
|
expect(harness.launches).toHaveLength(1);
|
|
expect(harness.launches[0]?.codexArgs).toEqual([
|
|
'--ask-for-approval',
|
|
'never',
|
|
'--sandbox',
|
|
'danger-full-access',
|
|
'--model',
|
|
'o3'
|
|
]);
|
|
});
|
|
|
|
it('preserves raw Codex approval flags in default mode', async () => {
|
|
const { session } = createSessionStub('default', [
|
|
'--ask-for-approval',
|
|
'on-request',
|
|
'--sandbox',
|
|
'workspace-write',
|
|
'--model',
|
|
'o3'
|
|
]);
|
|
|
|
await codexLocalLauncher(session as never);
|
|
|
|
expect(harness.launches).toHaveLength(1);
|
|
expect(harness.launches[0]?.codexArgs).toEqual([
|
|
'--ask-for-approval',
|
|
'on-request',
|
|
'--sandbox',
|
|
'workspace-write',
|
|
'--model',
|
|
'o3'
|
|
]);
|
|
});
|
|
|
|
it('keeps sandbox escalation available in safe-yolo mode', async () => {
|
|
const { session } = createSessionStub('safe-yolo', [
|
|
'--ask-for-approval',
|
|
'never',
|
|
'--sandbox',
|
|
'danger-full-access',
|
|
'--model',
|
|
'o3'
|
|
]);
|
|
|
|
await codexLocalLauncher(session as never);
|
|
|
|
expect(harness.launches).toHaveLength(1);
|
|
expect(harness.launches[0]?.codexArgs).toEqual([
|
|
'--ask-for-approval',
|
|
'on-request',
|
|
'--sandbox',
|
|
'workspace-write',
|
|
'--model',
|
|
'o3'
|
|
]);
|
|
});
|
|
|
|
it('does not emit a session warning while waiting for the first transcript path', async () => {
|
|
const { session, sessionEvents, getLocalLaunchFailure } = createSessionStub('default', undefined, 'c:\\workspace\\project');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(10_000);
|
|
await Promise.resolve();
|
|
expect(sessionEvents).toEqual([]);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(harness.launches.length).toBeGreaterThan(0);
|
|
expect(getLocalLaunchFailure()).toBeNull();
|
|
expect(sessionEvents).toEqual([]);
|
|
});
|
|
|
|
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);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
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();
|
|
expect(sessionEvents).toEqual([]);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(sessionEvents).toEqual([]);
|
|
});
|
|
|
|
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('tracks local turn context and stamps its model on usage', async () => {
|
|
const transcriptPath = await writeTranscriptMeta('codex-turn-context.jsonl', 'codex-thread-effort');
|
|
const { session, agentMessages, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
harness.sessionHookHandlers[0]?.('codex-thread-effort', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(100);
|
|
|
|
await appendFile(transcriptPath, [
|
|
JSON.stringify({
|
|
type: 'turn_context',
|
|
payload: { effort: 'max' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'turn_context',
|
|
payload: { model: 'gpt-5.4' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'token_count', info: {} }
|
|
})
|
|
].join('\n') + '\n');
|
|
await wait(700);
|
|
|
|
releaseRunBarrier?.();
|
|
await launcherPromise;
|
|
|
|
expect(getModelReasoningEffortUpdates()).toEqual(['max', null]);
|
|
expect(getModelReasoningEffort()).toBeNull();
|
|
expect(agentMessages).toContainEqual(expect.objectContaining({
|
|
type: 'token_count',
|
|
model: 'gpt-5.4',
|
|
usageSchema: 'hapi.usage.v1',
|
|
inputTokenSemantics: 'includes-cache'
|
|
}));
|
|
});
|
|
|
|
it('renders nested Code Mode plans and commands without their covered exec wrapper', async () => {
|
|
const transcriptPath = join(tempDir, 'codex-hook-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-hook' } }) + '\n'
|
|
);
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
|
|
hook_event_name: 'SessionStart',
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(100);
|
|
|
|
await appendFile(transcriptPath, JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call',
|
|
name: 'exec',
|
|
call_id: 'call-wrapper',
|
|
input: [
|
|
'await tools.update_plan({ plan: [{ step: "Inspect", status: "completed" }] });',
|
|
'const r = await tools.exec_command({ cmd: "pwd" });',
|
|
'text(r.output);'
|
|
].join('\n'),
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' }
|
|
}
|
|
}) + '\n');
|
|
await wait(700);
|
|
|
|
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
|
|
hook_event_name: 'PreToolUse',
|
|
turn_id: 'turn-hook',
|
|
cwd: '/tmp/worktree',
|
|
tool_name: 'update_plan',
|
|
tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] },
|
|
tool_use_id: 'exec-plan-1'
|
|
});
|
|
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
|
|
hook_event_name: 'PostToolUse',
|
|
turn_id: 'turn-hook',
|
|
cwd: '/tmp/worktree',
|
|
tool_name: 'update_plan',
|
|
tool_input: { plan: [{ step: 'Inspect', status: 'completed' }] },
|
|
tool_response: 'Plan updated',
|
|
tool_use_id: 'exec-plan-1'
|
|
});
|
|
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
|
|
hook_event_name: 'PreToolUse',
|
|
turn_id: 'turn-hook',
|
|
cwd: '/tmp/worktree',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'pwd' },
|
|
tool_use_id: 'exec-command-1'
|
|
});
|
|
harness.sessionHookHandlers[0]?.('codex-thread-hook', {
|
|
hook_event_name: 'PostToolUse',
|
|
turn_id: 'turn-hook',
|
|
cwd: '/tmp/worktree',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'pwd' },
|
|
tool_response: '/tmp/worktree\n',
|
|
tool_use_id: 'exec-command-1'
|
|
});
|
|
|
|
await appendFile(transcriptPath, JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call_output',
|
|
call_id: 'call-wrapper',
|
|
output: [{ type: 'input_text', text: '/tmp/worktree\n' }],
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-hook' }
|
|
}
|
|
}) + '\n');
|
|
await wait(700);
|
|
|
|
releaseRunBarrier?.();
|
|
await launcherPromise;
|
|
|
|
expect(agentMessages).toEqual([{
|
|
type: 'tool-call',
|
|
name: 'update_plan',
|
|
callId: 'exec-plan-1',
|
|
input: { plan: [{ step: 'Inspect', status: 'completed' }] },
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'exec-plan-1',
|
|
output: 'Plan updated',
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'CodexBash',
|
|
callId: 'exec-command-1',
|
|
input: {
|
|
command: 'pwd',
|
|
cwd: '/tmp/worktree',
|
|
source: 'codex-hook'
|
|
},
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'exec-command-1',
|
|
output: {
|
|
stdout: '/tmp/worktree\n',
|
|
stderr: '',
|
|
status: 'completed'
|
|
},
|
|
id: expect.any(String)
|
|
}]);
|
|
});
|
|
|
|
it('falls back to the top-level review transcript when a review subagent is active', async () => {
|
|
const originalCodexHome = process.env.CODEX_HOME;
|
|
process.env.CODEX_HOME = tempDir;
|
|
const now = new Date();
|
|
const sessionDirectory = join(
|
|
tempDir,
|
|
'sessions',
|
|
String(now.getUTCFullYear()),
|
|
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
|
String(now.getUTCDate()).padStart(2, '0')
|
|
);
|
|
await mkdir(sessionDirectory, { recursive: true });
|
|
const transcriptPath = join(sessionDirectory, 'rollout-review-primary.jsonl');
|
|
const reviewSubagentPath = join(sessionDirectory, 'rollout-review-subagent.jsonl');
|
|
const { session, userMessages, agentMessages } = createSessionStub(
|
|
'default',
|
|
['--cd', '/tmp/effective-codex-cwd'],
|
|
'/tmp/worktree',
|
|
null,
|
|
true,
|
|
true
|
|
);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
try {
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await vi.waitFor(() => expect(harness.launches).toHaveLength(1));
|
|
expect(session.sessionId).toBeNull();
|
|
|
|
await Promise.all([
|
|
writeFile(transcriptPath, [
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: {
|
|
id: 'review-primary',
|
|
cwd: '/tmp/effective-codex-cwd',
|
|
source: 'cli'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'user_message', message: '/review' }
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'agent_message', message: 'final review result' }
|
|
})
|
|
].join('\n') + '\n'),
|
|
writeFile(reviewSubagentPath, [
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: {
|
|
id: 'review-subagent',
|
|
cwd: '/tmp/effective-codex-cwd',
|
|
source: { subagent: 'review' }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'user_message', message: 'review instructions' }
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'agent_message', message: 'internal review work' }
|
|
})
|
|
].join('\n') + '\n')
|
|
]);
|
|
|
|
await vi.waitFor(
|
|
() => expect(session.sessionId).toBe('review-primary'),
|
|
{ timeout: 3_000, interval: 50 }
|
|
);
|
|
if (releaseRunBarrier) releaseRunBarrier();
|
|
await launcherPromise;
|
|
|
|
expect(session.transcriptPath).toBe(transcriptPath);
|
|
expect(userMessages).toContain('/review');
|
|
expect(agentMessages).toContainEqual(expect.objectContaining({
|
|
type: 'message',
|
|
message: 'final review result'
|
|
}));
|
|
expect(agentMessages).not.toContainEqual(expect.objectContaining({
|
|
type: 'message',
|
|
message: 'internal review work'
|
|
}));
|
|
} finally {
|
|
if (releaseRunBarrier) releaseRunBarrier();
|
|
if (originalCodexHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = originalCodexHome;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('replays imported transcript history only on the first local attachment', async () => {
|
|
const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl');
|
|
const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import' } }),
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old imported prompt' } }),
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } }),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'token_count',
|
|
info: { total_token_usage: { input_tokens: 100, output_tokens: 10 } }
|
|
}
|
|
})
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('codex-thread-import', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(300);
|
|
|
|
await appendFile(
|
|
transcriptPath,
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'tail before switch' } }) + '\n'
|
|
);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(userMessages).toEqual(['old imported prompt', 'tail before switch']);
|
|
expect(agentMessages.filter((message) => (
|
|
message as { message?: string }
|
|
).message === 'old imported message')).toHaveLength(1);
|
|
|
|
let releaseSecondRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseSecondRunBarrier = resolve;
|
|
});
|
|
|
|
const secondLauncherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[1]?.('codex-thread-import', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(300);
|
|
|
|
expect(userMessages).toEqual(['old imported prompt', 'tail before switch']);
|
|
expect(agentMessages.filter((message) => (
|
|
message as { message?: string }
|
|
).message === 'old imported message')).toHaveLength(1);
|
|
|
|
await appendFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'new local prompt' } }),
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'new local response' } }),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'token_count',
|
|
info: { total_token_usage: { input_tokens: 120, output_tokens: 12 } }
|
|
}
|
|
})
|
|
].join('\n') + '\n'
|
|
);
|
|
await wait(700);
|
|
|
|
if (releaseSecondRunBarrier) {
|
|
releaseSecondRunBarrier();
|
|
}
|
|
await secondLauncherPromise;
|
|
|
|
expect(userMessages).toEqual(['old imported prompt', 'tail before switch', 'new local prompt']);
|
|
expect(agentMessages.filter((message) => (
|
|
message as { message?: string }
|
|
).message === 'old imported message')).toHaveLength(1);
|
|
expect(agentMessages).toContainEqual({
|
|
type: 'message',
|
|
message: 'new local response',
|
|
id: expect.any(String)
|
|
});
|
|
const tokenMessages = agentMessages.filter((message) => (
|
|
message as { type?: string }
|
|
).type === 'token_count') as Array<Record<string, unknown>>;
|
|
expect(tokenMessages).toHaveLength(2);
|
|
expect(tokenMessages[0]).toMatchObject({
|
|
hapiUsageScope: 'imported-history',
|
|
usageSchema: 'hapi.usage.v1',
|
|
inputTokenSemantics: 'includes-cache'
|
|
});
|
|
expect(tokenMessages[0]).not.toHaveProperty('thread_id');
|
|
expect(tokenMessages[1]).toMatchObject({
|
|
threadId: 'codex-thread-import',
|
|
thread_id: 'codex-thread-import',
|
|
hapiUsageScope: 'managed',
|
|
usageSchema: 'hapi.usage.v1',
|
|
inputTokenSemantics: 'includes-cache'
|
|
});
|
|
});
|
|
|
|
it('replays semantic chat and tool events once and keeps a same-turn preface before its plan', async () => {
|
|
const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl');
|
|
const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import-response-item' } }),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: 'visible user message' }]
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'user_message', message: 'visible user message' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'function_call',
|
|
name: 'LegacyTool',
|
|
call_id: 'call-function',
|
|
arguments: '{"path":"README.md"}'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'function_call_output',
|
|
call_id: 'call-function',
|
|
output: { ok: true }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call',
|
|
call_id: 'call-exec',
|
|
name: 'exec',
|
|
input: 'pwd'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'exec_command_end',
|
|
call_id: 'call-exec',
|
|
output: '/tmp/worktree'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call_output',
|
|
call_id: 'call-exec',
|
|
output: [{ type: 'input_text', text: '/tmp/worktree' }]
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call',
|
|
call_id: 'call-patch',
|
|
name: 'apply_patch',
|
|
input: '*** Begin Patch\n*** End Patch'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'patch_apply_end',
|
|
call_id: 'call-patch',
|
|
output: 'Done!'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'custom_tool_call_output',
|
|
call_id: 'call-patch',
|
|
output: 'Done!'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'tool_search_call',
|
|
call_id: 'call-tool-search',
|
|
arguments: { query: 'hapi change title', limit: 5 }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'tool_search_output',
|
|
call_id: 'call-tool-search',
|
|
execution: 'client',
|
|
status: 'completed',
|
|
tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }]
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'web_search_call',
|
|
status: 'failed',
|
|
action: {
|
|
type: 'search',
|
|
query: 'Codex transcript format',
|
|
queries: ['Codex transcript format']
|
|
}
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'web_search_end', query: 'Codex transcript format' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'mcp_tool_call_end', call_id: 'call-mcp-summary' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'item_completed',
|
|
turn_id: 'turn-with-preface',
|
|
item: {
|
|
type: 'Plan',
|
|
id: 'plan-1',
|
|
text: '## Proposed plan\n\n1. Inspect\n2. Implement'
|
|
}
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'output_text',
|
|
text: 'visible assistant preface\n\n<proposed_plan>## Proposed plan\n\n1. Inspect\n2. Implement</proposed_plan>'
|
|
}],
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-with-preface' }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'agent_message',
|
|
message: 'visible assistant preface',
|
|
phase: 'final_answer'
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'task_complete', turn_id: 'turn-with-preface' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: '<environment_context>hidden context</environment_context>' }]
|
|
}
|
|
})
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('codex-thread-import-response-item', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(300);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(userMessages).toEqual(['visible user message']);
|
|
expect(getUserActivityCount()).toBe(0);
|
|
expect(agentMessages).toEqual([{
|
|
type: 'tool-call',
|
|
name: 'LegacyTool',
|
|
callId: 'call-function',
|
|
input: { path: 'README.md' },
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'call-function',
|
|
output: { ok: true },
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'exec',
|
|
callId: 'call-exec',
|
|
input: 'pwd',
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'call-exec',
|
|
output: [{ type: 'input_text', text: '/tmp/worktree' }],
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'apply_patch',
|
|
callId: 'call-patch',
|
|
input: '*** Begin Patch\n*** End Patch',
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'call-patch',
|
|
output: 'Done!',
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'ToolSearch',
|
|
callId: 'call-tool-search',
|
|
input: { query: 'hapi change title', limit: 5 },
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'call-tool-search',
|
|
output: {
|
|
execution: 'client',
|
|
tools: [{ name: 'mcp__hapi', description: 'Hapi tools' }]
|
|
},
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'WebSearch',
|
|
callId: expect.any(String),
|
|
input: {
|
|
type: 'search',
|
|
query: 'Codex transcript format',
|
|
queries: ['Codex transcript format']
|
|
},
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: expect.any(String),
|
|
output: null,
|
|
id: expect.any(String),
|
|
is_error: true
|
|
}, {
|
|
type: 'message',
|
|
message: 'visible assistant preface',
|
|
id: expect.any(String)
|
|
}, {
|
|
type: 'tool-call',
|
|
name: 'ExitPlanMode',
|
|
callId: 'codex-proposed-plan:plan-1',
|
|
input: { plan: '## Proposed plan\n\n1. Inspect\n2. Implement' },
|
|
id: 'plan-1'
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'codex-proposed-plan:plan-1',
|
|
output: null,
|
|
id: 'plan-1:result'
|
|
}]);
|
|
expect(agentMessages[9]).toMatchObject({
|
|
callId: (agentMessages[8] as { callId: string }).callId
|
|
});
|
|
});
|
|
|
|
it('replays Codex 0.147 completed messages and response-only final answers once', async () => {
|
|
const transcriptPath = join(tempDir, 'codex-import-0.147-transcript.jsonl');
|
|
const { session, userMessages, agentMessages, getUserActivityCount } = createSessionStub(
|
|
'default',
|
|
undefined,
|
|
'/tmp/worktree',
|
|
null,
|
|
true
|
|
);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-147' } }),
|
|
JSON.stringify({
|
|
type: 'turn_context',
|
|
payload: { turn_id: 'turn-147', model: 'gpt-5.6-sol' }
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'item_completed',
|
|
turn_id: 'turn-147',
|
|
item: {
|
|
type: 'UserMessage',
|
|
id: 'user-147',
|
|
content: [{ type: 'Text', text: 'visible 0.147 prompt' }]
|
|
}
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'item_completed',
|
|
turn_id: 'turn-147',
|
|
item: {
|
|
type: 'AgentMessage',
|
|
id: 'commentary-147',
|
|
phase: 'commentary',
|
|
content: [{ type: 'Text', text: 'visible 0.147 commentary' }]
|
|
}
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
id: 'commentary-147',
|
|
role: 'assistant',
|
|
phase: 'commentary',
|
|
content: [{ type: 'output_text', text: 'visible 0.147 commentary' }],
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-147' }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
id: 'final-147',
|
|
role: 'assistant',
|
|
phase: 'final_answer',
|
|
content: [{ type: 'output_text', text: 'visible 0.147 final answer' }],
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-147' }
|
|
}
|
|
})
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('codex-thread-147', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(300);
|
|
|
|
releaseRunBarrier?.();
|
|
await launcherPromise;
|
|
|
|
expect(userMessages).toEqual(['visible 0.147 prompt']);
|
|
expect(getUserActivityCount()).toBe(0);
|
|
expect(agentMessages).toEqual([{
|
|
type: 'message',
|
|
message: 'visible 0.147 commentary',
|
|
id: 'commentary-147'
|
|
}, {
|
|
type: 'message',
|
|
message: 'visible 0.147 final answer',
|
|
id: 'final-147'
|
|
}]);
|
|
});
|
|
|
|
it('replays a plan-only turn when the turn completes', async () => {
|
|
const transcriptPath = join(tempDir, 'codex-import-plan-only-transcript.jsonl');
|
|
const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-plan-only' } }),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: {
|
|
type: 'item_completed',
|
|
turn_id: 'turn-plan-only',
|
|
item: { type: 'Plan', id: 'plan-only', text: '## Plan only' }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: '<proposed_plan>## Plan only</proposed_plan>' }],
|
|
internal_chat_message_metadata_passthrough: { turn_id: 'turn-plan-only' }
|
|
}
|
|
}),
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'task_complete', turn_id: 'turn-plan-only' }
|
|
})
|
|
].join('\n') + '\n'
|
|
);
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('codex-thread-plan-only', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(300);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(agentMessages).toEqual([{
|
|
type: 'tool-call',
|
|
name: 'ExitPlanMode',
|
|
callId: 'codex-proposed-plan:plan-only',
|
|
input: { plan: '## Plan only' },
|
|
id: 'plan-only'
|
|
}, {
|
|
type: 'tool-call-result',
|
|
callId: 'codex-proposed-plan:plan-only',
|
|
output: null,
|
|
id: 'plan-only:result'
|
|
}]);
|
|
});
|
|
|
|
it('does not let a later non-clear hook replace the primary session', async () => {
|
|
const primaryTranscriptPath = await writeTranscriptMeta('primary-later-hook.jsonl', 'primary-thread');
|
|
const otherTranscriptPath = await writeTranscriptMeta('later-other-transcript.jsonl', 'other-thread');
|
|
const { session } = createSessionStub('default');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('primary-thread', {
|
|
transcript_path: primaryTranscriptPath,
|
|
source: 'startup'
|
|
});
|
|
await wait(100);
|
|
|
|
harness.sessionHookHandlers[0]?.('other-thread', {
|
|
transcript_path: otherTranscriptPath,
|
|
source: 'startup'
|
|
});
|
|
await wait(100);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(session.sessionId).toBe('primary-thread');
|
|
expect(session.transcriptPath).toBe(primaryTranscriptPath);
|
|
});
|
|
|
|
it('does not let a later hook without source replace the primary session', async () => {
|
|
const primaryTranscriptPath = await writeTranscriptMeta('primary-no-source.jsonl', 'primary-thread');
|
|
const otherTranscriptPath = await writeTranscriptMeta('other-no-source.jsonl', 'other-thread');
|
|
const { session } = createSessionStub('default');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('primary-thread', {
|
|
transcript_path: primaryTranscriptPath
|
|
});
|
|
await wait(100);
|
|
|
|
harness.sessionHookHandlers[0]?.('other-thread', {
|
|
transcript_path: otherTranscriptPath
|
|
});
|
|
await wait(100);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(session.sessionId).toBe('primary-thread');
|
|
expect(session.transcriptPath).toBe(primaryTranscriptPath);
|
|
});
|
|
|
|
it('allows a clear hook to replace the primary session', async () => {
|
|
const primaryTranscriptPath = await writeTranscriptMeta('primary-before-clear.jsonl', 'primary-thread');
|
|
const clearTranscriptPath = await writeTranscriptMeta('clear-transcript.jsonl', 'clear-thread');
|
|
const { session, agentMessages } = createSessionStub('default');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('primary-thread', {
|
|
transcript_path: primaryTranscriptPath,
|
|
source: 'startup'
|
|
});
|
|
await wait(100);
|
|
|
|
await appendFile(
|
|
primaryTranscriptPath,
|
|
JSON.stringify({
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: 'same text in both threads' }]
|
|
}
|
|
}) + '\n'
|
|
);
|
|
await wait(300);
|
|
|
|
harness.sessionHookHandlers[0]?.('clear-thread', {
|
|
transcript_path: clearTranscriptPath,
|
|
source: 'clear'
|
|
});
|
|
await wait(100);
|
|
|
|
await appendFile(
|
|
clearTranscriptPath,
|
|
JSON.stringify({
|
|
type: 'event_msg',
|
|
payload: { type: 'agent_message', message: 'same text in both threads' }
|
|
}) + '\n'
|
|
);
|
|
await wait(300);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(session.sessionId).toBe('clear-thread');
|
|
expect(session.transcriptPath).toBe(clearTranscriptPath);
|
|
expect(agentMessages.filter((message) => (
|
|
message as { message?: string }
|
|
).message === 'same text in both threads')).toHaveLength(2);
|
|
});
|
|
|
|
it('ignores mismatched session metadata from the active transcript scanner', async () => {
|
|
const transcriptPath = await writeTranscriptMeta('mismatched-scanner.jsonl', 'primary-thread');
|
|
const { session } = createSessionStub('default');
|
|
let releaseRunBarrier: (() => void) | undefined;
|
|
harness.runBarrier = new Promise((resolve) => {
|
|
releaseRunBarrier = resolve;
|
|
});
|
|
|
|
const launcherPromise = codexLocalLauncher(session as never);
|
|
await wait(50);
|
|
|
|
harness.sessionHookHandlers[0]?.('primary-thread', {
|
|
transcript_path: transcriptPath
|
|
});
|
|
await wait(100);
|
|
|
|
await appendFile(
|
|
transcriptPath,
|
|
JSON.stringify({ type: 'session_meta', payload: { id: 'unexpected-thread' } }) + '\n'
|
|
);
|
|
|
|
await wait(2300);
|
|
|
|
if (releaseRunBarrier) {
|
|
releaseRunBarrier();
|
|
}
|
|
await launcherPromise;
|
|
|
|
expect(session.sessionId).toBe('primary-thread');
|
|
expect(session.transcriptPath).toBe(transcriptPath);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|