From b0e896e2866880ac25d1e24106b7548e998c7a5a Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Thu, 21 May 2026 01:48:59 -0700 Subject: [PATCH] fix: stop codex transcript replay after hooks migration (#2365) --- .../integrations/CodexCliInstaller.ts | 91 ++++++++++++-- src/services/transcripts/config.ts | 45 +++++-- src/services/transcripts/watcher.ts | 9 +- src/services/worker-service.ts | 22 +++- src/shared/SettingsDefaultsManager.ts | 2 + tests/install-non-tty.test.ts | 19 +-- tests/integration/codex-cli-installer.test.ts | 61 ++++++++++ tests/transcripts/config.test.ts | 75 ++++++++++++ .../transcripts/watcher-start-at-end.test.ts | 111 ++++++++++++++++++ 9 files changed, 398 insertions(+), 37 deletions(-) create mode 100644 tests/integration/codex-cli-installer.test.ts create mode 100644 tests/transcripts/config.test.ts create mode 100644 tests/transcripts/watcher-start-at-end.test.ts diff --git a/src/services/integrations/CodexCliInstaller.ts b/src/services/integrations/CodexCliInstaller.ts index 2093e3a7..8dd177b7 100644 --- a/src/services/integrations/CodexCliInstaller.ts +++ b/src/services/integrations/CodexCliInstaller.ts @@ -1,7 +1,7 @@ import path from 'path'; import { homedir } from 'os'; import { execFileSync, spawnSync } from 'child_process'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { logger } from '../../utils/logger.js'; import { paths } from '../../shared/paths.js'; @@ -9,7 +9,10 @@ import { paths } from '../../shared/paths.js'; const CODEX_DIR = path.join(homedir(), '.codex'); const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md'); const CODEX_TRANSCRIPT_WATCH_CONFIG_PATH = paths.transcriptsConfig(); +const CODEX_CONFIG_PATH = path.join(CODEX_DIR, 'config.toml'); const MARKETPLACE_NAME = 'claude-mem-local'; +const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`; +const LEGACY_CODEX_PLUGIN_IDS = ['claude-mem@thedotmack']; const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0'; const REQUIRED_MARKETPLACE_FILES = [ path.join('.agents', 'plugins', 'marketplace.json'), @@ -131,6 +134,74 @@ function registerCodexMarketplace(marketplaceRoot: string): void { runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]); } +export function setTomlBooleanInTable(content: string, header: string, key: string, enabled: boolean): string { + const booleanLine = `${key} = ${enabled ? 'true' : 'false'}`; + const lines = content.split('\n'); + const headerIndex = lines.findIndex((line) => line.trim() === header); + + if (headerIndex === -1) { + const trimmed = content.trimEnd(); + return `${trimmed}${trimmed ? '\n\n' : ''}${header}\n${booleanLine}\n`; + } + + let sectionEnd = headerIndex + 1; + while (sectionEnd < lines.length && !/^\s*\[/.test(lines[sectionEnd])) { + sectionEnd += 1; + } + + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const keyPattern = new RegExp(`^\\s*${escapedKey}\\s*=`); + const keyIndex = lines.findIndex( + (line, index) => index > headerIndex && index < sectionEnd && keyPattern.test(line), + ); + + if (keyIndex === -1) { + lines.splice(headerIndex + 1, 0, booleanLine); + } else { + lines[keyIndex] = booleanLine; + } + + return lines.join('\n'); +} + +export function setTomlPluginEnabled(content: string, pluginId: string, enabled: boolean): string { + const escapedPluginId = pluginId.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return setTomlBooleanInTable(content, `[plugins."${escapedPluginId}"]`, 'enabled', enabled); +} + +export function setTomlFeatureEnabled(content: string, featureName: string, enabled: boolean): string { + return setTomlBooleanInTable(content, '[features]', featureName, enabled); +} + +function writeCodexPluginConfig(enabled: boolean): boolean { + if (!enabled && !existsSync(CODEX_CONFIG_PATH)) return false; + mkdirSync(CODEX_DIR, { recursive: true }); + const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, 'utf-8') : ''; + let next = current; + + if (enabled) { + next = setTomlFeatureEnabled(next, 'hooks', true); + } + for (const legacyPluginId of LEGACY_CODEX_PLUGIN_IDS) { + next = setTomlPluginEnabled(next, legacyPluginId, false); + } + next = setTomlPluginEnabled(next, CODEX_PLUGIN_ID, enabled); + + if (next === current) return false; + writeFileSync(CODEX_CONFIG_PATH, next); + return true; +} + +function enableCodexPluginConfig(): void { + const changed = writeCodexPluginConfig(true); + console.log(` Enabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already enabled)'}`); +} + +function disableCodexPluginConfig(): void { + const changed = writeCodexPluginConfig(false); + console.log(` Disabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already disabled)'}`); +} + function parseSemver(value: string): [number, number, number] | null { const match = value.match(/(\d+)\.(\d+)\.(\d+)/); if (!match) return null; @@ -284,16 +355,12 @@ export async function installCodexCli(marketplaceRootOverride?: string): Promise console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`); registerCodexMarketplace(marketplaceRoot); + enableCodexPluginConfig(); runCodexBestEffort( ['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME], 'Refreshed Codex marketplace and installed plugin cache.', 'Could not refresh Codex marketplace cache; reinstall or upgrade claude-mem from /plugins if Codex still uses old MCP config', ); - runCodexBestEffort( - ['features', 'enable', 'plugin_hooks'], - 'Enabled Codex plugin_hooks so claude-mem hooks can run.', - 'Could not enable Codex plugin_hooks; run `codex features enable plugin_hooks` if context hooks do not appear', - ); if (!cleanupLegacyCodexAgentsMdContext()) { console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`); } @@ -309,9 +376,7 @@ Plugin source: ${marketplaceRoot} Next steps: 1. Open Codex CLI in your project - 2. Run /plugins - 3. Install claude-mem from the claude-mem (local) marketplace - 4. Restart Codex CLI after install so MCP tools and plugin hooks reload + 2. Restart any running Codex sessions so native hooks are loaded For a fresh setup, the supported entry point is: npx claude-mem@latest install @@ -329,6 +394,14 @@ export function uninstallCodexCli(): number { let failed = false; + try { + disableCodexPluginConfig(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nCodex plugin config update failed: ${message}`); + failed = true; + } + try { if (commandExists('codex')) { runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]); diff --git a/src/services/transcripts/config.ts b/src/services/transcripts/config.ts index 2a2f31f0..6a5f1926 100644 --- a/src/services/transcripts/config.ts +++ b/src/services/transcripts/config.ts @@ -7,10 +7,10 @@ import type { TranscriptSchema, TranscriptWatchConfig } from './types.js'; export const DEFAULT_CONFIG_PATH = paths.transcriptsConfig(); export const DEFAULT_STATE_PATH = paths.transcriptsState(); -const CODEX_SAMPLE_SCHEMA: TranscriptSchema = { +export const CODEX_SAMPLE_SCHEMA: TranscriptSchema = { name: 'codex', version: '0.3', - description: 'Schema for Codex session JSONL files under ~/.codex/sessions.', + description: 'Legacy schema for Codex session JSONL files. Codex native hooks are preferred.', events: [ { name: 'session-meta', @@ -109,20 +109,39 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = { export const SAMPLE_CONFIG: TranscriptWatchConfig = { version: 1, - schemas: { - codex: CODEX_SAMPLE_SCHEMA - }, - watches: [ - { - name: 'codex', - path: '~/.codex/sessions/**/*.jsonl', - schema: 'codex', - startAtEnd: true - } - ], + schemas: {}, + watches: [], stateFile: DEFAULT_STATE_PATH }; +export function isNativeHookBackedCodexWatch(watch: { name?: string; path?: string; schema?: string | TranscriptSchema }): boolean { + const schemaName = typeof watch.schema === 'string' ? watch.schema : watch.schema?.name; + const nameOrSchemaIsCodex = watch.name === 'codex' || schemaName === 'codex'; + if (!nameOrSchemaIsCodex || !watch.path) return false; + + const normalizedPath = expandHomePath(watch.path).replace(/\\/g, '/'); + const codexSessionsRoot = join(homedir(), '.codex', 'sessions').replace(/\\/g, '/'); + return normalizedPath === `${codexSessionsRoot}/**/*.jsonl`; +} + +export function filterNativeHookBackedCodexWatches( + config: TranscriptWatchConfig, + allowCodexTranscriptIngestion: boolean +): { config: TranscriptWatchConfig; removed: number } { + if (allowCodexTranscriptIngestion) { + return { config, removed: 0 }; + } + + const watches = config.watches.filter(watch => !isNativeHookBackedCodexWatch(watch)); + return { + config: { + ...config, + watches, + }, + removed: config.watches.length - watches.length, + }; +} + export function expandHomePath(inputPath: string): string { if (!inputPath) return inputPath; if (inputPath.startsWith('~')) { diff --git a/src/services/transcripts/watcher.ts b/src/services/transcripts/watcher.ts index 89d26535..32bd9454 100644 --- a/src/services/transcripts/watcher.ts +++ b/src/services/transcripts/watcher.ts @@ -122,7 +122,7 @@ export class TranscriptWatcher { const files = this.resolveWatchFiles(resolvedPath); for (const filePath of files) { - await this.addTailer(filePath, watch, schema, true); + await this.addTailer(filePath, watch, schema); } const watchRoot = this.deepestNonGlobAncestor(resolvedPath); @@ -143,7 +143,7 @@ export class TranscriptWatcher { const matches = this.resolveWatchFiles(resolvedPath); for (const filePath of matches) { if (!this.tailers.has(filePath)) { - void this.addTailer(filePath, watch, schema, false); + void this.addTailer(filePath, watch, schema); } } }); @@ -223,15 +223,14 @@ export class TranscriptWatcher { private async addTailer( filePath: string, watch: WatchTarget, - schema: TranscriptSchema, - initialDiscovery: boolean + schema: TranscriptSchema ): Promise { if (this.tailers.has(filePath)) return; const sessionIdOverride = this.extractSessionIdFromPath(filePath); let offset = this.state.offsets[filePath] ?? 0; - if (offset === 0 && watch.startAtEnd && initialDiscovery) { + if (offset === 0 && watch.startAtEnd) { try { offset = statSync(filePath).size; } catch (error: unknown) { diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index 50db8e3a..7d773fac 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -81,7 +81,7 @@ import { TimelineService } from './worker/TimelineService.js'; import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js'; import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js'; import { setIngestContext, attachIngestGeneratorStarter } from './worker/http/shared.js'; -import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig } from './transcripts/config.js'; +import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, filterNativeHookBackedCodexWatches, loadTranscriptWatchConfig } from './transcripts/config.js'; import { TranscriptWatcher } from './transcripts/watcher.js'; import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js'; @@ -471,9 +471,27 @@ export class WorkerService implements WorkerRef { return; } - const transcriptConfig = loadTranscriptWatchConfig(configPath); + const allowCodexTranscriptIngestion = settings.CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION === 'true'; + const { config: transcriptConfig, removed } = filterNativeHookBackedCodexWatches( + loadTranscriptWatchConfig(configPath), + allowCodexTranscriptIngestion + ); const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH); + if (removed > 0) { + logger.warn('TRANSCRIPT', 'Skipped Codex transcript watch because native Codex hooks are authoritative', { + removed, + optInSetting: 'CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION=true', + }); + } + + if (transcriptConfig.watches.length === 0) { + logger.info('TRANSCRIPT', 'Transcript watcher config has no active watches; skipping automatic transcript capture', { + configPath: resolvedConfigPath, + }); + return; + } + try { this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath); await this.transcriptWatcher.start(); diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index f55cc074..d4a7af31 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -42,6 +42,7 @@ export interface SettingsDefaults { CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string; CLAUDE_MEM_TRANSCRIPTS_ENABLED: string; CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; + CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION: string; CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: string; CLAUDE_MEM_EXCLUDED_PROJECTS: string; @@ -117,6 +118,7 @@ export class SettingsDefaultsManager { CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true', CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'), + CLAUDE_MEM_CODEX_TRANSCRIPT_INGESTION: 'false', CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses CLAUDE_MEM_HOOK_FAIL_LOUD_THRESHOLD: '3', // Plan 05 Phase 8 — escalate to exit code 2 after N consecutive worker-unreachable hook invocations CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths diff --git a/tests/install-non-tty.test.ts b/tests/install-non-tty.test.ts index 17b25873..030041e1 100644 --- a/tests/install-non-tty.test.ts +++ b/tests/install-non-tty.test.ts @@ -151,13 +151,15 @@ describe('Install Non-TTY Support', () => { expect(registerRegion).toContain("['plugin', 'marketplace', 'add', marketplaceRoot]"); }); - it('enables Codex plugin hooks during install', () => { + it('enables Codex hooks and claude-mem plugin config during install', () => { const installRegion = codexInstallerSource.slice( codexInstallerSource.indexOf('export async function installCodexCli'), codexInstallerSource.indexOf('export function uninstallCodexCli'), ); - expect(installRegion).toContain("['features', 'enable', 'plugin_hooks']"); - expect(installRegion).toContain('codex features enable plugin_hooks'); + expect(codexInstallerSource).toContain("setTomlFeatureEnabled(next, 'hooks', true)"); + expect(codexInstallerSource).toContain("const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`"); + expect(installRegion).toContain('enableCodexPluginConfig()'); + expect(installRegion).not.toContain('plugin_hooks'); }); it('captures Codex CLI output for install failure reporting', () => { @@ -211,13 +213,14 @@ describe('Install Non-TTY Support', () => { it('does not seed new Codex transcript watcher configs with AGENTS context injection', () => { expect(transcriptConfigSource).toContain("name: 'codex'"); - const codexWatchRegion = transcriptConfigSource.slice( - transcriptConfigSource.indexOf("name: 'codex'"), + const sampleConfigRegion = transcriptConfigSource.slice( + transcriptConfigSource.indexOf('export const SAMPLE_CONFIG'), transcriptConfigSource.indexOf('stateFile: DEFAULT_STATE_PATH'), ); - expect(codexWatchRegion).toContain("path: '~/.codex/sessions/**/*.jsonl'"); - expect(codexWatchRegion).not.toContain("mode: 'agents'"); - expect(codexWatchRegion).not.toContain('updateOn'); + expect(sampleConfigRegion).toContain('watches: []'); + expect(sampleConfigRegion).not.toContain("path: '~/.codex/sessions/**/*.jsonl'"); + expect(sampleConfigRegion).not.toContain("mode: 'agents'"); + expect(sampleConfigRegion).not.toContain('updateOn'); }); }); diff --git a/tests/integration/codex-cli-installer.test.ts b/tests/integration/codex-cli-installer.test.ts new file mode 100644 index 00000000..7df0c01e --- /dev/null +++ b/tests/integration/codex-cli-installer.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { + setTomlFeatureEnabled, + setTomlPluginEnabled, +} from '../../src/services/integrations/CodexCliInstaller.js'; + +describe('Codex CLI installer config repair', () => { + it('adds claude-mem plugin enablement when missing', () => { + const result = setTomlPluginEnabled('model = "gpt-5.5"\n', 'claude-mem@claude-mem-local', true); + + expect(result).toContain('[plugins."claude-mem@claude-mem-local"]'); + expect(result).toContain('enabled = true'); + }); + + it('updates existing plugin enablement in place', () => { + const input = [ + '[plugins."claude-mem@thedotmack"]', + 'enabled = true', + '', + '[marketplaces.claude-mem-local]', + 'source_type = "git"', + '', + ].join('\n'); + + const result = setTomlPluginEnabled(input, 'claude-mem@thedotmack', false); + + expect(result).toContain('[plugins."claude-mem@thedotmack"]\nenabled = false'); + expect(result).toContain('[marketplaces.claude-mem-local]'); + }); + + it('inserts enabled into an existing plugin section without touching the next section', () => { + const input = [ + '[plugins."claude-mem@claude-mem-local"]', + '', + '[hooks.state]', + '', + ].join('\n'); + + const result = setTomlPluginEnabled(input, 'claude-mem@claude-mem-local', true); + + expect(result).toContain('[plugins."claude-mem@claude-mem-local"]\nenabled = true\n'); + expect(result).toContain('[hooks.state]'); + }); + + it('enables the current Codex hooks feature flag', () => { + const input = [ + '[features]', + 'shell_snapshot = true', + '', + '[plugins."claude-mem@claude-mem-local"]', + 'enabled = true', + '', + ].join('\n'); + + const result = setTomlFeatureEnabled(input, 'hooks', true); + + expect(result).toContain('[features]\nhooks = true\nshell_snapshot = true'); + expect(result).toContain('[plugins."claude-mem@claude-mem-local"]'); + expect(result).not.toContain('codex_hooks'); + }); +}); diff --git a/tests/transcripts/config.test.ts b/tests/transcripts/config.test.ts new file mode 100644 index 00000000..9873cd77 --- /dev/null +++ b/tests/transcripts/config.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'bun:test'; +import { homedir } from 'os'; +import { join } from 'path'; +import { + CODEX_SAMPLE_SCHEMA, + SAMPLE_CONFIG, + filterNativeHookBackedCodexWatches, + isNativeHookBackedCodexWatch, +} from '../../src/services/transcripts/config.js'; +import type { TranscriptWatchConfig } from '../../src/services/transcripts/types.js'; + +describe('transcript watcher config', () => { + it('does not auto-watch Codex transcripts in the sample config', () => { + expect(SAMPLE_CONFIG.watches).toEqual([]); + }); + + it('recognizes the legacy Codex session transcript watch', () => { + expect(isNativeHookBackedCodexWatch({ + name: 'codex', + path: '~/.codex/sessions/**/*.jsonl', + schema: 'codex', + })).toBe(true); + + expect(isNativeHookBackedCodexWatch({ + name: 'codex', + path: join(homedir(), '.codex', 'sessions', '**', '*.jsonl'), + schema: CODEX_SAMPLE_SCHEMA, + })).toBe(true); + }); + + it('does not treat custom transcript watches as native Codex hooks', () => { + expect(isNativeHookBackedCodexWatch({ + name: 'codex-archive', + path: '~/custom-codex-export/**/*.jsonl', + schema: 'codex', + })).toBe(false); + + expect(isNativeHookBackedCodexWatch({ + name: 'other', + path: '~/.codex/sessions/**/*.jsonl', + schema: 'other', + })).toBe(false); + }); + + it('strips legacy Codex watches unless explicitly opted in', () => { + const config: TranscriptWatchConfig = { + version: 1, + schemas: { + codex: CODEX_SAMPLE_SCHEMA, + }, + watches: [ + { + name: 'codex', + path: '~/.codex/sessions/**/*.jsonl', + schema: 'codex', + startAtEnd: true, + }, + { + name: 'custom', + path: '~/custom/**/*.jsonl', + schema: 'codex', + startAtEnd: true, + }, + ], + }; + + const filtered = filterNativeHookBackedCodexWatches(config, false); + expect(filtered.removed).toBe(1); + expect(filtered.config.watches.map(watch => watch.name)).toEqual(['custom']); + + const allowed = filterNativeHookBackedCodexWatches(config, true); + expect(allowed.removed).toBe(0); + expect(allowed.config.watches).toHaveLength(2); + }); +}); diff --git a/tests/transcripts/watcher-start-at-end.test.ts b/tests/transcripts/watcher-start-at-end.test.ts new file mode 100644 index 00000000..19a23c77 --- /dev/null +++ b/tests/transcripts/watcher-start-at-end.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import type { NormalizedHookInput } from '../../src/cli/types.js'; +import type { TranscriptSchema, WatchTarget } from '../../src/services/transcripts/types.js'; + +const sessionInitCalls: NormalizedHookInput[] = []; + +mock.module('../../src/cli/handlers/session-init.js', () => ({ + sessionInitHandler: { + execute: async (input: NormalizedHookInput) => { + sessionInitCalls.push(input); + return { continue: true, suppressOutput: true }; + }, + }, +})); + +import { logger } from '../../src/utils/logger.js'; +import { TranscriptWatcher } from '../../src/services/transcripts/watcher.js'; + +const waitForAsyncTail = () => new Promise(resolve => setTimeout(resolve, 50)); + +describe('TranscriptWatcher startAtEnd', () => { + let tmpRoot: string; + let loggerSpies: ReturnType[] = []; + + beforeEach(() => { + sessionInitCalls.length = 0; + tmpRoot = join(tmpdir(), `claude-mem-transcript-watch-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmpRoot, { recursive: true }); + loggerSpies = [ + spyOn(logger, 'info').mockImplementation(() => {}), + spyOn(logger, 'debug').mockImplementation(() => {}), + spyOn(logger, 'warn').mockImplementation(() => {}), + spyOn(logger, 'error').mockImplementation(() => {}), + ]; + }); + + afterEach(() => { + loggerSpies.forEach(spy => spy.mockRestore()); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('does not replay history from transcript files discovered after startup', async () => { + const sessionId = '019e050e-7ae0-71b2-b19f-6cc428e5763a'; + const filePath = join(tmpRoot, `${sessionId}.jsonl`); + const statePath = join(tmpRoot, 'state.json'); + + writeFileSync( + filePath, + `${JSON.stringify({ + type: 'event', + payload: { + type: 'user_message', + session_id: sessionId, + message: 'historical prompt that must not be replayed', + }, + })}\n`, + 'utf8', + ); + + const schema: TranscriptSchema = { + name: 'codex-test', + events: [ + { + name: 'user-message', + match: { path: 'payload.type', equals: 'user_message' }, + action: 'session_init', + fields: { + sessionId: 'payload.session_id', + prompt: 'payload.message', + }, + }, + ], + }; + const watch: WatchTarget = { + name: 'codex', + path: join(tmpRoot, '*.jsonl'), + schema, + startAtEnd: true, + }; + const watcher = new TranscriptWatcher({ version: 1, watches: [watch] }, statePath); + + await (watcher as any).addTailer(filePath, watch, schema); + await waitForAsyncTail(); + + expect(sessionInitCalls).toHaveLength(0); + + appendFileSync( + filePath, + `${JSON.stringify({ + type: 'event', + payload: { + type: 'user_message', + session_id: sessionId, + message: 'live prompt', + }, + })}\n`, + 'utf8', + ); + + (watcher as any).tailers.get(filePath)?.poke(); + await waitForAsyncTail(); + watcher.stop(); + + const prompts = sessionInitCalls.map(call => call.prompt); + expect(prompts).toContain('live prompt'); + expect(prompts).not.toContain('historical prompt that must not be replayed'); + }); +});