mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
fix(hooks): dedupe file-context injection per session (#3480)
* fix(hooks): dedupe file-context injection per session (#3480) The PreToolUse file-context handler re-injected the same "prior observations" timeline on every Read/Grep of an unchanged file within a session — the only gate was file-mtime staleness, with no per-(session, file) memoization. Repeatedly reading the same file (iterative debugging, long sessions) multiplied context cost with zero new information. Add a small persistent per-session store (one JSON file per session under DATA_DIR/file-context-seen) keyed by resolved file path → newest injected observation epoch. A repeated unchanged Read is now skipped; re-injection happens only when a newer observation has been recorded since. In-memory memoization is impossible here because each hook runs in a freshly spawned process. Stale session files are pruned (7-day TTL) on a session's first write so the store stays bounded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): make file-context dedupe keys collision-free (#3480) Address two P1 review findings on the per-session file-context dedupe store: - Session-id store filenames: replace the non-injective char-replace scheme (`[^A-Za-z0-9_-] -> _`) with a sha256 hex digest of the complete session id. `a.b` and `a:b` no longer collapse to the same `a_b.json` and cross-suppress each other's initial injection. Still path-safe and traversal-proof. - Dedupe path key: normalize with `path.resolve(cwd, filePath)` for absolute inputs too, so dot-segment aliases (`/p/src/../src/f.ts` vs `/p/src/f.ts`) collapse to one canonical key instead of double-injecting the same file. RED -> GREEN: two new regression tests in tests/hooks/file-context.test.ts fail on the pre-fix code (collision suppresses the distinct session; alias bypasses dedupe) and pass after. Full file-context suite 17 pass; typecheck clean; full `bun test tests` baseline unchanged (27 pre-existing failures before and after, +2 new passing tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): order file-context store setup before prune check (#3486) * fix(hooks): persist the file-context gate in SQLite, not a JSON side-store (#3480) Review feedback on #3486 asked for plan-20 (#3608) step 4: the file-context injection gate must be a row in the main database keyed per (session, file, observation epoch), not a JSON file under DATA_DIR. What changed: - The gate moves from one JSON file per session under `file-context-seen/` to a `file_context_injections` table in `claude-mem.db`. The table is device- local: sync enumerates the tables it pushes explicitly, and this is not one. - `wasFileContextInjected` + `recordFileContextInjection` collapse into a single `claimFileContextInjection`. Claiming IS recording, in one `INSERT ... ON CONFLICT ... RETURNING` statement, so two concurrent Reads of the same file in one session can no longer both inject — the read-before-write race the previous store shipped with (and documented as acceptable) is gone. - The upsert guard `excluded.observation_epoch > stored` keeps the stored epoch monotonic, so a hook that finishes late carrying an older epoch neither wins the claim nor rolls the row back to a stale value. - The claim moved to the very end of `buildFileContextTimeline`, after every other reason to bail out, so a suppressed injection never burns the claim. - `resolveDbPath()` resolves the database path at call time. `DB_PATH` freezes `DATA_DIR` at import, which is right for the long-lived worker but wrong for a per-invocation hook process that must honour a `CLAUDE_MEM_DATA_DIR` set after the module loaded (and is what lets the tests point at a temp dir). Failing open is preserved: an unopenable database or a failed claim injects rather than throwing, which is never worse than the un-deduped behaviour. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// #3480 — per-(session, file, observation-epoch) injection gate for the
|
||||
// file-context PreToolUse handler, persisted in the main SQLite database
|
||||
// (plan-20 #3608 step 4: the gate is a DB row, not a JSON side-store).
|
||||
//
|
||||
// Each hook invocation is a freshly spawned process (via bun-runner), so an
|
||||
// in-memory Set cannot remember what was already surfaced this session. The
|
||||
// gate therefore lives in `claude-mem.db` next to every other durable fact:
|
||||
// one row per (session, file) carrying the newest observation epoch already
|
||||
// injected for it. A repeated Read of the same unchanged file is skipped
|
||||
// unless a NEWER observation has landed since that injection.
|
||||
//
|
||||
// The row is keyed on (session_id, file_path) and *carries* the epoch rather
|
||||
// than keying on the triple: the gate only ever asks "was this pair already
|
||||
// served at an epoch >= the current newest one", so an upsert keeps the exact
|
||||
// same semantics as an epoch-keyed row while leaving one row per pair instead
|
||||
// of one row per observation.
|
||||
//
|
||||
// Rows are device-local scratch state. Sync enumerates the tables it pushes
|
||||
// explicitly (observations, session_summaries, user_prompts — see
|
||||
// SessionStore.initializeSyncHubLaunchBaseline), so a table it does not name is
|
||||
// never drained: a gate row never leaves the box.
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { resolveDbPath } from '../../shared/paths.js';
|
||||
import { applySqliteConnectionPragmas } from '../../services/sqlite/connection.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
const GATE_TABLE_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS file_context_injections (
|
||||
session_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
observation_epoch INTEGER NOT NULL,
|
||||
injected_at_epoch INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, file_path)
|
||||
)
|
||||
`;
|
||||
|
||||
// Claiming and recording are ONE statement, so two concurrent Reads of the same
|
||||
// file in the same session cannot both pass the gate: SQLite serializes the
|
||||
// writers, the first inserts and gets its row back, the second conflicts and is
|
||||
// filtered out by the WHERE, which returns no row. `RETURNING` is what reports
|
||||
// the outcome — bun:sqlite's `.changes` is unreliable after RETURNING (see the
|
||||
// note in SessionStore.ts), so the claim reads the returned row instead.
|
||||
//
|
||||
// The `excluded.observation_epoch > ...` guard also keeps the stored epoch
|
||||
// monotonic: a hook that finishes late carrying an older epoch neither wins the
|
||||
// claim nor rolls the row back to that stale value.
|
||||
const CLAIM_GATE_SQL = `
|
||||
INSERT INTO file_context_injections
|
||||
(session_id, file_path, observation_epoch, injected_at_epoch)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, file_path) DO UPDATE SET
|
||||
observation_epoch = excluded.observation_epoch,
|
||||
injected_at_epoch = excluded.injected_at_epoch
|
||||
WHERE excluded.observation_epoch > file_context_injections.observation_epoch
|
||||
RETURNING 1 AS claimed
|
||||
`;
|
||||
|
||||
const SELECT_SESSION_SEEN_SQL = `
|
||||
SELECT 1 AS hit FROM file_context_injections WHERE session_id = ? LIMIT 1
|
||||
`;
|
||||
|
||||
const DELETE_EXPIRED_SQL = `
|
||||
DELETE FROM file_context_injections WHERE injected_at_epoch < ?
|
||||
`;
|
||||
|
||||
// Sessions rarely outlive a few days; drop stale rows so the table never grows
|
||||
// unbounded. Cheap because we only sweep on a session's first claimed row.
|
||||
const DAY_MS = 86_400_000;
|
||||
const GATE_ROW_TTL_DAYS = 7;
|
||||
const GATE_ROW_TTL_MS = GATE_ROW_TTL_DAYS * DAY_MS;
|
||||
|
||||
function describeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function closeQuietly(db: Database): void {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// The handle is already being discarded — nothing left to salvage.
|
||||
}
|
||||
}
|
||||
|
||||
// One handle per process, keyed by path so a data-dir change (tests, a
|
||||
// re-pointed CLAUDE_MEM_DATA_DIR) reopens instead of serving the stale file.
|
||||
let cachedGateDb: { path: string; db: Database } | null = null;
|
||||
|
||||
function openGateDb(): Database | null {
|
||||
const dbPath = resolveDbPath();
|
||||
if (cachedGateDb?.path === dbPath) return cachedGateDb.db;
|
||||
if (cachedGateDb) {
|
||||
closeQuietly(cachedGateDb.db);
|
||||
cachedGateDb = null;
|
||||
}
|
||||
|
||||
let opened: Database | null = null;
|
||||
try {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
opened = new Database(dbPath);
|
||||
applySqliteConnectionPragmas(opened);
|
||||
opened.run(GATE_TABLE_DDL);
|
||||
cachedGateDb = { path: dbPath, db: opened };
|
||||
return cachedGateDb.db;
|
||||
} catch (err) {
|
||||
// An unopenable/unwritable DB must never break a Read — fail open and let
|
||||
// the injection happen, which is strictly safe (worst case: one redundant
|
||||
// block).
|
||||
logger.debug('HOOK', 'file-context gate database unavailable, skipping dedupe', {
|
||||
dbPath,
|
||||
error: describeError(err),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
// Opened but never adopted into the cache (the DDL threw): close it here or
|
||||
// the handle leaks for the life of the process.
|
||||
if (opened && cachedGateDb?.db !== opened) closeQuietly(opened);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredRows(db: Database, sessionId: string, now: number): void {
|
||||
if (db.query(SELECT_SESSION_SEEN_SQL).get(sessionId) != null) return;
|
||||
db.query(DELETE_EXPIRED_SQL).run(now - GATE_ROW_TTL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim the right to inject this file's timeline into this session.
|
||||
*
|
||||
* Returns `true` when the caller should inject — either the (session, file)
|
||||
* pair was never surfaced, or a NEWER observation has landed since it was.
|
||||
* Returns `false` when the pair was already served at an epoch >= this one, so
|
||||
* there is nothing new to say.
|
||||
*
|
||||
* The claim is recorded by the same statement that grants it, so there is no
|
||||
* window in which a concurrent hook can claim the same pair. Fails open: when
|
||||
* the gate is unusable the caller injects, which is never worse than the
|
||||
* un-deduped behavior this replaces.
|
||||
*/
|
||||
export function claimFileContextInjection(
|
||||
sessionId: string,
|
||||
resolvedPath: string,
|
||||
newestObservationEpoch: number,
|
||||
): boolean {
|
||||
if (!sessionId) return true;
|
||||
const db = openGateDb();
|
||||
if (!db) return true;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
pruneExpiredRows(db, sessionId, now);
|
||||
return db.query(CLAIM_GATE_SQL).get(sessionId, resolvedPath, newestObservationEpoch, now) != null;
|
||||
} catch (err) {
|
||||
logger.debug('HOOK', 'file-context gate claim failed, injecting without dedupe', {
|
||||
error: describeError(err),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { shouldTrackProject } from '../../shared/should-track-project.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { claimFileContextInjection } from './file-context-dedupe.js';
|
||||
|
||||
const FILE_READ_GATE_MIN_BYTES = 1_500;
|
||||
|
||||
@@ -213,7 +214,12 @@ async function buildFileContextTimeline(input: NormalizedHookInput, filePath: st
|
||||
|
||||
const context = getProjectContext(input.cwd);
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
// path.resolve normalizes dot-segments (`a/../b` -> `b`) for BOTH absolute and
|
||||
// relative inputs, so `/p/src/../src/f.ts` and `/p/src/f.ts` collapse to one
|
||||
// canonical dedupe key instead of two. It ignores `cwd` when `filePath` is
|
||||
// already absolute, so absolute inputs are still honored verbatim (minus the
|
||||
// redundant `.`/`..` segments).
|
||||
const absolutePath = path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
|
||||
// #2691 — PostToolUse stores whatever path form the observer recorded
|
||||
@@ -252,20 +258,33 @@ async function buildFileContextTimeline(input: NormalizedHookInput, filePath: st
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
|
||||
if (fileMtimeMs > 0 && fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never empty: the `observations.length === 0` guard above already returned,
|
||||
// and deduplicateObservations only ever drops same-session duplicates and
|
||||
// truncates to DISPLAY_LIMIT, so at least one row always survives.
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
|
||||
// #3480 — skip re-injecting the same still-valid timeline for a file already
|
||||
// surfaced this session; re-inject only once a newer observation has landed.
|
||||
// Claimed last, after every other reason to bail out, so a suppressed
|
||||
// injection never burns the claim — and claiming IS recording, so two
|
||||
// concurrent Reads of this file cannot both inject.
|
||||
if (!claimFileContextInjection(input.sessionId, absolutePath, newestObservationMs)) {
|
||||
logger.debug('HOOK', 'File context already surfaced this session, skipping re-injection', {
|
||||
filePath: relativePath,
|
||||
sessionId: input.sessionId,
|
||||
newestObservationMs,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -52,7 +52,18 @@ export const MARKETPLACE_ROOT = join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces
|
||||
|
||||
export const LOGS_DIR = join(DATA_DIR, 'logs');
|
||||
export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const DB_FILENAME = 'claude-mem.db';
|
||||
|
||||
/**
|
||||
* Database path resolved at CALL time. `DB_PATH` freezes `DATA_DIR` at import,
|
||||
* which is right for long-lived processes but wrong for anything that must
|
||||
* honor a `CLAUDE_MEM_DATA_DIR` set after this module was loaded.
|
||||
*/
|
||||
export function resolveDbPath(): string {
|
||||
return join(resolveDataDir(), DB_FILENAME);
|
||||
}
|
||||
|
||||
export const DB_PATH = join(DATA_DIR, DB_FILENAME);
|
||||
|
||||
export const OBSERVER_SESSIONS_DIR = join(DATA_DIR, 'observer-sessions');
|
||||
|
||||
@@ -95,7 +106,7 @@ export const paths = {
|
||||
serverPort: () => join(DATA_DIR, '.server-beta.port'),
|
||||
serverRuntime: () => join(DATA_DIR, '.server-beta.runtime.json'),
|
||||
settings: () => join(DATA_DIR, 'settings.json'),
|
||||
database: () => join(DATA_DIR, 'claude-mem.db'),
|
||||
database: () => join(DATA_DIR, DB_FILENAME),
|
||||
chroma: () => join(DATA_DIR, 'chroma'),
|
||||
combinedCerts: () => join(DATA_DIR, 'combined_certs.pem'),
|
||||
transcriptsConfig: () => join(DATA_DIR, 'transcript-watch.json'),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, spyOn, mock } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { resolveDbPath } from '../../src/shared/paths.js';
|
||||
|
||||
// Capture the REAL modules BEFORE mocking so afterAll can restore them.
|
||||
// bun's `mock.module` is process-global and sticky; `mock.restore()` does NOT
|
||||
@@ -54,6 +56,7 @@ mock.module('../../src/utils/project-filter.js', () => ({
|
||||
}));
|
||||
|
||||
import { fileContextHandler } from '../../src/cli/handlers/file-context.js';
|
||||
import { claimFileContextInjection } from '../../src/cli/handlers/file-context-dedupe.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
const PADDING = 'x'.repeat(2_000);
|
||||
@@ -81,11 +84,19 @@ function makeObservationsResponse(observations: Array<{ id: number; created_at_e
|
||||
);
|
||||
}
|
||||
|
||||
let prevDataDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'file-context-test-'));
|
||||
testFile = join(tmpDir, 'test.md');
|
||||
writeFileSync(testFile, PADDING);
|
||||
|
||||
// #3480 — the per-(session,file) injection gate persists in the SQLite DB
|
||||
// under DATA_DIR. Point it at a fresh per-test dir so each test starts with an
|
||||
// empty gate table and the real ~/.claude-mem is never touched.
|
||||
prevDataDir = process.env.CLAUDE_MEM_DATA_DIR;
|
||||
process.env.CLAUDE_MEM_DATA_DIR = join(tmpDir, 'data');
|
||||
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
@@ -100,6 +111,8 @@ afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
fetchSpy = null;
|
||||
}
|
||||
if (prevDataDir === undefined) delete process.env.CLAUDE_MEM_DATA_DIR;
|
||||
else process.env.CLAUDE_MEM_DATA_DIR = prevDataDir;
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
@@ -330,6 +343,196 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
expect(pathParams.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('injects once per (session, file) — a second unchanged Read is deduped (#3480)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
// mockImplementation (not mockResolvedValue): each call needs a FRESH
|
||||
// Response — a Response body can only be consumed once.
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
const first = await fileContextHandler.execute({
|
||||
sessionId: 'sess-dedupe',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(first.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
|
||||
const second = await fileContextHandler.execute({
|
||||
sessionId: 'sess-dedupe',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(second.continue).toBe(true);
|
||||
expect(second.hookSpecificOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists the injection gate as a SQLite row, not a JSON side-store (#3608 step 4)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
const injected = await fileContextHandler.execute({
|
||||
sessionId: 'sess-sqlite-gate',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(injected.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
|
||||
// The gate is a row in the main database keyed by (session, file) and
|
||||
// carrying the observation epoch it was served at — see plan-20 #3608.
|
||||
const db = new Database(resolveDbPath(), { readonly: true });
|
||||
try {
|
||||
const row = db.query(`
|
||||
SELECT file_path, observation_epoch
|
||||
FROM file_context_injections
|
||||
WHERE session_id = ?
|
||||
`).get('sess-sqlite-gate') as { file_path: string; observation_epoch: number } | null;
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.file_path).toBe(testFile);
|
||||
expect(row!.observation_epoch).toBe(future);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('grants the injection claim to exactly one caller for the same (session, file, epoch) (#3608 step 4)', () => {
|
||||
// Claiming IS recording: a check-then-write gate would hand both callers a
|
||||
// green light and inject the same block twice.
|
||||
const epoch = Date.now() + 60_000;
|
||||
const claims = [
|
||||
claimFileContextInjection('sess-claim', testFile, epoch),
|
||||
claimFileContextInjection('sess-claim', testFile, epoch),
|
||||
];
|
||||
expect(claims.filter(Boolean)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never rolls the stored epoch back to an older observation (#3608 step 4)', async () => {
|
||||
const newer = Date.now() + 120_000;
|
||||
const older = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 2, created_at_epoch: newer }]))
|
||||
);
|
||||
await fileContextHandler.execute({
|
||||
sessionId: 'sess-monotonic',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
|
||||
// A hook that finishes late carrying an OLDER epoch must neither inject nor
|
||||
// downgrade the row — otherwise the next Read re-injects a stale timeline.
|
||||
fetchSpy.mockRestore();
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: older }]))
|
||||
);
|
||||
const late = await fileContextHandler.execute({
|
||||
sessionId: 'sess-monotonic',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(late.hookSpecificOutput).toBeUndefined();
|
||||
|
||||
const db = new Database(resolveDbPath(), { readonly: true });
|
||||
try {
|
||||
const row = db.query(`
|
||||
SELECT observation_epoch FROM file_context_injections WHERE session_id = ?
|
||||
`).get('sess-monotonic') as { observation_epoch: number } | null;
|
||||
expect(row!.observation_epoch).toBe(newer);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails open when the gate database cannot be opened (#3608 step 4)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
// Data dir nested under a regular FILE: every mkdir/open against it fails
|
||||
// with ENOTDIR, so the gate is unusable. A broken gate must never break a
|
||||
// Read — it degrades to "always inject", never to an error or a swallowed
|
||||
// injection.
|
||||
const blocker = join(tmpDir, 'not-a-directory');
|
||||
writeFileSync(blocker, '');
|
||||
process.env.CLAUDE_MEM_DATA_DIR = join(blocker, 'data');
|
||||
|
||||
const first = await fileContextHandler.execute({
|
||||
sessionId: 'sess-broken-gate',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
const second = await fileContextHandler.execute({
|
||||
sessionId: 'sess-broken-gate',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
|
||||
expect(first.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
expect(second.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
});
|
||||
|
||||
it('re-injects when a NEW observation is recorded since the last injection (#3480)', async () => {
|
||||
const first_epoch = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: first_epoch }]))
|
||||
);
|
||||
const first = await fileContextHandler.execute({
|
||||
sessionId: 'sess-new-obs',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(first.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
|
||||
// A newer observation lands → re-injection is expected, not deduped.
|
||||
fetchSpy.mockRestore();
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([
|
||||
{ id: 1, created_at_epoch: first_epoch },
|
||||
{ id: 2, created_at_epoch: first_epoch + 30_000, title: 'Fresh observation' },
|
||||
]))
|
||||
);
|
||||
const second = await fileContextHandler.execute({
|
||||
sessionId: 'sess-new-obs',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(second.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
});
|
||||
|
||||
it('dedupe is scoped per session — a different session still gets its injection (#3480)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
await fileContextHandler.execute({
|
||||
sessionId: 'sess-A',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
|
||||
const other = await fileContextHandler.execute({
|
||||
sessionId: 'sess-B',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(other.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
});
|
||||
|
||||
it('skips directories before querying file history', async () => {
|
||||
const directoryPath = join(tmpDir, 'large-dir');
|
||||
mkdirSync(directoryPath);
|
||||
@@ -348,4 +551,58 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
expect(result.hookSpecificOutput).toBeUndefined();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates sessions whose ids differ only in path-sanitized chars (#3486)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
// "a.b" and "a:b" are DISTINCT sessions that both collapse to "a_b" under a
|
||||
// naive char-replace scheme. The second session must still get its injection.
|
||||
await fileContextHandler.execute({
|
||||
sessionId: 'a.b',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
|
||||
const other = await fileContextHandler.execute({
|
||||
sessionId: 'a:b',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(other.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
});
|
||||
|
||||
it('dedupes dot-segment path aliases of the same file in a session (#3486)', async () => {
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() =>
|
||||
Promise.resolve(makeObservationsResponse([{ id: 1, created_at_epoch: future }]))
|
||||
);
|
||||
|
||||
const subDir = join(tmpDir, 'sub');
|
||||
mkdirSync(subDir);
|
||||
// Raw string keeps the `..` segment (path.join would collapse it) so the
|
||||
// alias and the canonical path name the SAME file via different spellings.
|
||||
const aliasPath = `${subDir}/../test.md`;
|
||||
|
||||
const first = await fileContextHandler.execute({
|
||||
sessionId: 'sess-alias',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
expect(first.hookSpecificOutput?.additionalContext).toContain('prior observations');
|
||||
|
||||
const second = await fileContextHandler.execute({
|
||||
sessionId: 'sess-alias',
|
||||
cwd: tmpDir,
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: aliasPath },
|
||||
});
|
||||
expect(second.continue).toBe(true);
|
||||
expect(second.hookSpecificOutput).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user