Files
claude-mem/tests/hooks/file-context.test.ts
T
Alex Newman 56db06811e Add native Codex hooks integration (#2319)
* Add native Codex hooks integration

* Address Codex review feedback

* Use durable Codex marketplace root

* Address Codex file context review feedback

* Harden Codex installer review paths

* Report Codex legacy cleanup failures

* fix: keep MCP manifests in marketplace sync

* fix: bundle zod in MCP server

* fix: warn on Codex legacy cleanup failure

* Fix hook observation readiness timeouts

* Address Codex hook review notes

* Tighten Codex MCP file context matching

* Resolve final Codex review nits

* Add Codex marketplace version guidance

* Reset worker failure counter on API fallback

* Fix Codex cat flag file extraction
2026-05-06 01:55:27 -07:00

255 lines
8.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { mkdirSync, mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
import { tmpdir, homedir } from 'os';
import { join } from 'path';
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
get: (key: string) => {
if (key === 'CLAUDE_MEM_DATA_DIR') return join(homedir(), '.claude-mem');
return '';
},
getInt: () => 0,
loadFromFile: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: [] }),
},
}));
mock.module('../../src/shared/worker-utils.js', () => ({
ensureWorkerRunning: () => Promise.resolve(true),
getWorkerPort: () => 37777,
workerHttpRequest: (apiPath: string, options?: any) => {
const url = `http://127.0.0.1:37777${apiPath}`;
return globalThis.fetch(url, {
method: options?.method ?? 'GET',
headers: options?.headers,
body: options?.body,
});
},
}));
mock.module('../../src/utils/project-name.js', () => ({
getProjectName: () => 'test-project',
getProjectContext: () => ({ allProjects: ['test-project'] }),
}));
mock.module('../../src/utils/project-filter.js', () => ({
isProjectExcluded: () => false,
}));
import { fileContextHandler } from '../../src/cli/handlers/file-context.js';
import { logger } from '../../src/utils/logger.js';
const PADDING = 'x'.repeat(2_000);
let tmpDir: string;
let testFile: string;
let loggerSpies: ReturnType<typeof spyOn>[] = [];
let fetchSpy: ReturnType<typeof spyOn> | null = null;
function makeObservationsResponse(observations: Array<{ id: number; created_at_epoch: number; type?: string; title?: string }>) {
return new Response(
JSON.stringify({
observations: observations.map(o => ({
id: o.id,
memory_session_id: `session-${o.id}`,
title: o.title ?? `Observation ${o.id}`,
type: o.type ?? 'discovery',
created_at_epoch: o.created_at_epoch,
files_read: JSON.stringify([]),
files_modified: JSON.stringify(['test.md']),
})),
count: observations.length,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'file-context-test-'));
testFile = join(tmpDir, 'test.md');
writeFileSync(testFile, PADDING);
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
];
});
afterEach(() => {
loggerSpies.forEach(s => s.mockRestore());
if (fetchSpy) {
fetchSpy.mockRestore();
fetchSpy = null;
}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
describe('fileContextHandler — #2094 (no Read mutation)', () => {
it('injects timeline context but never sets updatedInput on an unconstrained Read', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
expect(result.hookSpecificOutput).toBeDefined();
expect(result.hookSpecificOutput!.additionalContext).toContain('prior observations');
expect((result.hookSpecificOutput as any).updatedInput).toBeUndefined();
});
it('does not set updatedInput on a targeted Read either', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile, offset: 289, limit: 140 },
});
expect(result.hookSpecificOutput).toBeDefined();
expect((result.hookSpecificOutput as any).updatedInput).toBeUndefined();
});
it('skips entirely when file mtime is newer than newest observation (#1719 still honored)', async () => {
const stale = Date.now() - 3_600_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([
{ id: 1, created_at_epoch: stale },
{ id: 2, created_at_epoch: stale - 1000 },
])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
expect(result.continue).toBe(true);
expect(result.hookSpecificOutput).toBeUndefined();
});
it('still injects context when file mtime is older than newest observation', async () => {
const past = (Date.now() - 3_600_000) / 1000;
utimesSync(testFile, past, past);
const now = Date.now();
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: now }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
expect(result.hookSpecificOutput).toBeDefined();
expect(result.hookSpecificOutput!.additionalContext).toContain('prior observations');
expect((result.hookSpecificOutput as any).updatedInput).toBeUndefined();
});
it('header text no longer claims the file was truncated', async () => {
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Read',
toolInput: { file_path: testFile },
});
const ctx = result.hookSpecificOutput!.additionalContext as string;
expect(ctx).not.toContain('Only line 1 was read');
expect(ctx).toContain('full requested section');
});
it('accepts a Codex filePaths array and joins per-file context blocks', async () => {
const otherFile = join(tmpDir, 'other.md');
writeFileSync(otherFile, PADDING);
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((url: string | URL | Request) => {
const text = String(url);
if (text.includes('other.md')) {
return Promise.resolve(makeObservationsResponse([{ id: 2, created_at_epoch: future, title: 'Other file context' }]));
}
return Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future, title: 'Main file context' }]));
});
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Bash',
toolInput: { filePaths: [testFile, otherFile] },
});
const ctx = result.hookSpecificOutput!.additionalContext as string;
expect(ctx).toContain('Main file context');
expect(ctx).toContain('Other file context');
expect(ctx).toContain('\n\n---\n\n');
});
it('keeps successful timelines when one file lookup fails', async () => {
const otherFile = join(tmpDir, 'other.md');
writeFileSync(otherFile, PADDING);
const future = Date.now() + 60_000;
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((url: string | URL | Request) => {
const text = String(url);
if (text.includes('other.md')) {
return Promise.reject(new Error('worker unavailable'));
}
return Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future, title: 'Main file context' }]));
});
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Bash',
toolInput: { filePaths: [testFile, otherFile] },
});
const ctx = result.hookSpecificOutput!.additionalContext as string;
expect(ctx).toContain('Main file context');
expect(ctx).not.toContain('worker unavailable');
});
it('skips directories before querying file history', async () => {
const directoryPath = join(tmpDir, 'large-dir');
mkdirSync(directoryPath);
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
makeObservationsResponse([{ id: 1, created_at_epoch: Date.now() + 60_000 }])
);
const result = await fileContextHandler.execute({
sessionId: 'sess',
cwd: tmpDir,
toolName: 'Bash',
toolInput: { filePaths: [directoryPath] },
});
expect(result.continue).toBe(true);
expect(result.hookSpecificOutput).toBeUndefined();
expect(fetchSpy).not.toHaveBeenCalled();
});
});