fix: stop codex transcript replay after hooks migration (#2365)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof spyOn>[] = [];
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user