mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { mkdir, writeFile, appendFile, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { existsSync } from 'node:fs';
|
|
import { createCodexSessionScanner } from './codexSessionScanner';
|
|
import type { CodexSessionEvent } from './codexEventConverter';
|
|
|
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
describe('codexSessionScanner', () => {
|
|
let testDir: string;
|
|
let sessionsDir: string;
|
|
let sessionFile: string;
|
|
let originalCodexHome: string | undefined;
|
|
let scanner: Awaited<ReturnType<typeof createCodexSessionScanner>> | null = null;
|
|
let events: CodexSessionEvent[] = [];
|
|
|
|
beforeEach(async () => {
|
|
testDir = join(tmpdir(), `codex-scanner-${Date.now()}`);
|
|
sessionsDir = join(testDir, 'sessions', '2025', '12', '22');
|
|
await mkdir(sessionsDir, { recursive: true });
|
|
|
|
originalCodexHome = process.env.CODEX_HOME;
|
|
process.env.CODEX_HOME = testDir;
|
|
|
|
events = [];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (scanner) {
|
|
await scanner.cleanup();
|
|
scanner = null;
|
|
}
|
|
|
|
if (originalCodexHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = originalCodexHome;
|
|
}
|
|
|
|
if (existsSync(testDir)) {
|
|
await rm(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('emits only new events after startup', async () => {
|
|
const sessionId = 'session-123';
|
|
sessionFile = join(sessionsDir, `codex-${sessionId}.jsonl`);
|
|
|
|
const initialLines = [
|
|
JSON.stringify({ type: 'session_meta', payload: { id: sessionId } }),
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } })
|
|
];
|
|
|
|
await writeFile(sessionFile, initialLines.join('\n') + '\n');
|
|
|
|
scanner = await createCodexSessionScanner({
|
|
sessionId,
|
|
onEvent: (event) => events.push(event)
|
|
});
|
|
|
|
await wait(150);
|
|
expect(events).toHaveLength(0);
|
|
|
|
const newLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-1', arguments: '{}' }
|
|
});
|
|
await appendFile(sessionFile, newLine + '\n');
|
|
|
|
await wait(200);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].type).toBe('response_item');
|
|
});
|
|
|
|
it('limits session scan to dates within the start window', async () => {
|
|
const referenceTimestampMs = Date.parse('2025-12-22T00:00:00.000Z');
|
|
const windowMs = 2 * 60 * 1000;
|
|
const matchingSessionId = 'session-222';
|
|
const outsideSessionId = 'session-999';
|
|
const outsideDir = join(testDir, 'sessions', '2025', '12', '20');
|
|
const matchingFile = join(sessionsDir, `codex-${matchingSessionId}.jsonl`);
|
|
const outsideFile = join(outsideDir, `codex-${outsideSessionId}.jsonl`);
|
|
|
|
await mkdir(outsideDir, { recursive: true });
|
|
const baseLines = [
|
|
JSON.stringify({ type: 'session_meta', payload: { id: matchingSessionId, cwd: '/data/github/happy/hapi', timestamp: '2025-12-22T00:00:30.000Z' } }),
|
|
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } })
|
|
];
|
|
await writeFile(matchingFile, baseLines.join('\n') + '\n');
|
|
await writeFile(
|
|
outsideFile,
|
|
JSON.stringify({ type: 'session_meta', payload: { id: outsideSessionId, cwd: '/data/github/happy/hapi', timestamp: '2025-12-20T00:00:00.000Z' } }) + '\n'
|
|
);
|
|
|
|
scanner = await createCodexSessionScanner({
|
|
sessionId: null,
|
|
cwd: '/data/github/happy/hapi',
|
|
startupTimestampMs: referenceTimestampMs,
|
|
sessionStartWindowMs: windowMs,
|
|
onEvent: (event) => events.push(event)
|
|
});
|
|
|
|
await wait(200);
|
|
expect(events).toHaveLength(0);
|
|
|
|
const newLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-2', arguments: '{}' }
|
|
});
|
|
await appendFile(matchingFile, newLine + '\n');
|
|
|
|
await wait(200);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].type).toBe('response_item');
|
|
});
|
|
|
|
it('fails fast when cwd is missing and no sessionId is provided', async () => {
|
|
const sessionId = 'session-missing-cwd';
|
|
const matchFailedMessage = 'No cwd provided for Codex session matching; refusing to fallback.';
|
|
sessionFile = join(sessionsDir, `codex-${sessionId}.jsonl`);
|
|
|
|
await writeFile(
|
|
sessionFile,
|
|
JSON.stringify({ type: 'session_meta', payload: { id: sessionId } }) + '\n'
|
|
);
|
|
|
|
let failureMessage: string | null = null;
|
|
scanner = await createCodexSessionScanner({
|
|
sessionId: null,
|
|
onEvent: (event) => events.push(event),
|
|
onSessionMatchFailed: (message) => {
|
|
failureMessage = message;
|
|
}
|
|
});
|
|
|
|
await wait(150);
|
|
expect(failureMessage).toBe(matchFailedMessage);
|
|
expect(events).toHaveLength(0);
|
|
|
|
const newLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-3', arguments: '{}' }
|
|
});
|
|
await appendFile(sessionFile, newLine + '\n');
|
|
|
|
await wait(200);
|
|
expect(events).toHaveLength(0);
|
|
});
|
|
|
|
it('adopts a reused older session file when fresh matching activity appears after startup', async () => {
|
|
const reusedSessionId = 'session-reused-old-file';
|
|
const targetCwd = '/data/github/happy/hapi';
|
|
const startupTimestampMs = Date.now();
|
|
const now = new Date(startupTimestampMs);
|
|
const currentSessionsDir = join(
|
|
testDir,
|
|
'sessions',
|
|
String(now.getFullYear()),
|
|
String(now.getMonth() + 1).padStart(2, '0'),
|
|
String(now.getDate()).padStart(2, '0')
|
|
);
|
|
await mkdir(currentSessionsDir, { recursive: true });
|
|
sessionFile = join(currentSessionsDir, `codex-${reusedSessionId}.jsonl`);
|
|
|
|
await writeFile(
|
|
sessionFile,
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: {
|
|
id: reusedSessionId,
|
|
cwd: targetCwd,
|
|
timestamp: new Date(startupTimestampMs - 10 * 60 * 1000).toISOString()
|
|
}
|
|
}) + '\n'
|
|
);
|
|
|
|
let matchedSessionId: string | null = null;
|
|
scanner = await createCodexSessionScanner({
|
|
sessionId: null,
|
|
cwd: targetCwd,
|
|
startupTimestampMs,
|
|
onEvent: (event) => events.push(event),
|
|
onSessionFound: (sessionId) => {
|
|
matchedSessionId = sessionId;
|
|
}
|
|
});
|
|
|
|
await wait(150);
|
|
expect(events).toHaveLength(0);
|
|
expect(matchedSessionId).toBeNull();
|
|
|
|
const newLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused', arguments: '{}' }
|
|
});
|
|
await appendFile(sessionFile, newLine + '\n');
|
|
|
|
await wait(2300);
|
|
expect(matchedSessionId).toBe(reusedSessionId);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].type).toBe('response_item');
|
|
});
|
|
|
|
it('does not adopt a reused session when first fresh matching activity is ambiguous', async () => {
|
|
const targetCwd = '/data/github/happy/hapi';
|
|
const startupTimestampMs = Date.now();
|
|
const now = new Date(startupTimestampMs);
|
|
const currentSessionsDir = join(
|
|
testDir,
|
|
'sessions',
|
|
String(now.getFullYear()),
|
|
String(now.getMonth() + 1).padStart(2, '0'),
|
|
String(now.getDate()).padStart(2, '0')
|
|
);
|
|
await mkdir(currentSessionsDir, { recursive: true });
|
|
|
|
const firstSessionId = 'session-reused-a';
|
|
const secondSessionId = 'session-reused-b';
|
|
const firstFile = join(currentSessionsDir, `codex-${firstSessionId}.jsonl`);
|
|
const secondFile = join(currentSessionsDir, `codex-${secondSessionId}.jsonl`);
|
|
const oldTimestamp = new Date(startupTimestampMs - 10 * 60 * 1000).toISOString();
|
|
|
|
await writeFile(
|
|
firstFile,
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: { id: firstSessionId, cwd: targetCwd, timestamp: oldTimestamp }
|
|
}) + '\n'
|
|
);
|
|
await writeFile(
|
|
secondFile,
|
|
JSON.stringify({
|
|
type: 'session_meta',
|
|
payload: { id: secondSessionId, cwd: targetCwd, timestamp: oldTimestamp }
|
|
}) + '\n'
|
|
);
|
|
|
|
let matchedSessionId: string | null = null;
|
|
scanner = await createCodexSessionScanner({
|
|
sessionId: null,
|
|
cwd: targetCwd,
|
|
startupTimestampMs,
|
|
onEvent: (event) => events.push(event),
|
|
onSessionFound: (sessionId) => {
|
|
matchedSessionId = sessionId;
|
|
}
|
|
});
|
|
|
|
await wait(150);
|
|
expect(matchedSessionId).toBeNull();
|
|
|
|
const firstNewLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-a-1', arguments: '{}' }
|
|
});
|
|
const secondNewLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-b-1', arguments: '{}' }
|
|
});
|
|
await appendFile(firstFile, firstNewLine + '\n');
|
|
await appendFile(secondFile, secondNewLine + '\n');
|
|
|
|
await wait(2300);
|
|
expect(matchedSessionId).toBeNull();
|
|
expect(events).toHaveLength(0);
|
|
|
|
const laterUniqueLine = JSON.stringify({
|
|
type: 'response_item',
|
|
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-a-2', arguments: '{}' }
|
|
});
|
|
await appendFile(firstFile, laterUniqueLine + '\n');
|
|
|
|
await wait(2300);
|
|
expect(matchedSessionId).toBeNull();
|
|
expect(events).toHaveLength(0);
|
|
});
|
|
});
|