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:
Ben Younes
2026-09-11 05:17:01 +02:00
committed by GitHub
parent e92a2d0acf
commit 1c6c187688
4 changed files with 460 additions and 14 deletions
+159
View File
@@ -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;
}
}
+31 -12
View File
@@ -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
View File
@@ -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'),
+257
View File
@@ -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();
});
});