From 95482ce721e07e6e2b8146b731b855ba182a5881 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 23:04:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Session=20Memory?= =?UTF-8?q?=20=E5=A4=9A=E5=AD=98=E5=82=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown 文件存储的本地记忆系统,支持多 store 管理、 entry 增删改查和归档,存储于 ~/.claude/local-memory/。 Co-Authored-By: glm-5-turbo --- .../__tests__/multiStore.test.ts | 308 ++++++++++++++ .../SessionMemory/__tests__/prompts.test.ts | 390 ++++++++++++++++++ src/services/SessionMemory/prompts.ts | 6 + 3 files changed, 704 insertions(+) create mode 100644 src/services/SessionMemory/__tests__/multiStore.test.ts create mode 100644 src/services/SessionMemory/__tests__/prompts.test.ts diff --git a/src/services/SessionMemory/__tests__/multiStore.test.ts b/src/services/SessionMemory/__tests__/multiStore.test.ts new file mode 100644 index 000000000..14dae5501 --- /dev/null +++ b/src/services/SessionMemory/__tests__/multiStore.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// No mocks needed — multiStore.ts is pure fs, no log/debug/bun:bundle side effects. + +describe('multiStore', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('listStores returns empty when no stores exist', async () => { + const { listStores } = await import('../multiStore.js') + expect(listStores()).toEqual([]) + }) + + test('createStore creates a store directory', async () => { + const { createStore, listStores } = await import('../multiStore.js') + createStore('my-store') + expect(listStores()).toContain('my-store') + }) + + test('createStore throws if store already exists', async () => { + const { createStore } = await import('../multiStore.js') + createStore('duplicate') + expect(() => createStore('duplicate')).toThrow('already exists') + }) + + test('setEntry and getEntry round-trip', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('notes') + setEntry('notes', 'hello', '# Hello\nThis is a note.') + expect(getEntry('notes', 'hello')).toBe('# Hello\nThis is a note.') + }) + + test('getEntry returns null for missing key', async () => { + const { createStore, getEntry } = await import('../multiStore.js') + createStore('empty-store') + expect(getEntry('empty-store', 'nonexistent')).toBeNull() + }) + + test('cross-store isolation: entries in different stores do not bleed', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('store-a') + createStore('store-b') + setEntry('store-a', 'shared-key', 'value-from-a') + setEntry('store-b', 'shared-key', 'value-from-b') + expect(getEntry('store-a', 'shared-key')).toBe('value-from-a') + expect(getEntry('store-b', 'shared-key')).toBe('value-from-b') + }) + + test('listEntries returns keys in a store', async () => { + const { createStore, setEntry, listEntries } = await import( + '../multiStore.js' + ) + createStore('listing') + setEntry('listing', 'alpha', 'a') + setEntry('listing', 'beta', 'b') + const entries = listEntries('listing') + expect(entries).toContain('alpha') + expect(entries).toContain('beta') + }) + + test('deleteEntry removes entry and returns true', async () => { + const { createStore, setEntry, deleteEntry, getEntry } = await import( + '../multiStore.js' + ) + createStore('del-store') + setEntry('del-store', 'to-remove', 'temp') + expect(deleteEntry('del-store', 'to-remove')).toBe(true) + expect(getEntry('del-store', 'to-remove')).toBeNull() + }) + + test('deleteEntry returns false for missing entry', async () => { + const { createStore, deleteEntry } = await import('../multiStore.js') + createStore('del-store-2') + expect(deleteEntry('del-store-2', 'ghost')).toBe(false) + }) + + test('archiveStore renames directory with .archived suffix', async () => { + const { createStore, archiveStore, listStores, listAllStores } = + await import('../multiStore.js') + createStore('to-archive') + archiveStore('to-archive') + expect(listStores()).not.toContain('to-archive') + expect(listAllStores()).toContain('to-archive.archived') + }) + + test('large entry round-trip (>500KB)', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('large') + const largeValue = 'A'.repeat(512 * 1024) + setEntry('large', 'big-entry', largeValue) + expect(getEntry('large', 'big-entry')).toBe(largeValue) + }) + + test('Unicode key is rejected (path-safety policy from PR-0a)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('unicode-store') + // Unicode keys are now rejected by validateKey to keep path-safety + // semantics OS-portable and to enable safe permission rule contents. + // Value can still contain unicode — only the key is constrained. + expect(() => + setEntry('unicode-store', '日本語キー', 'value with 日本語'), + ).toThrow(/invalid key chars/i) + }) + + test('value with unicode is still stored fine (only key is constrained)', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('unicode-value-store') + setEntry('unicode-value-store', 'ascii_key', 'value with 日本語 ✓') + expect(getEntry('unicode-value-store', 'ascii_key')).toBe( + 'value with 日本語 ✓', + ) + }) + + test('backward compat: pre-existing a_b.md file remains readable as a_b key', async () => { + // Simulates the pre-PR-0a state where a user wrote setEntry('s', 'a_b', X) + // OR setEntry('s', 'a/b', X) — both produced a_b.md on disk. After PR-0a, + // the new validateKey rejects 'a/b' but accepts 'a_b'. Existing a_b.md + // files must still load via getEntry('s', 'a_b'). + const { createStore, getEntry } = await import('../multiStore.js') + createStore('compat-store') + const storeDir = join(tmpDir, 'local-memory', 'compat-store') + writeFileSync(join(storeDir, 'a_b.md'), 'legacy content') + expect(getEntry('compat-store', 'a_b')).toBe('legacy content') + }) + + test('key collision regression: a/b is rejected, no longer collides with a_b', async () => { + const { createStore, setEntry, getEntry } = await import('../multiStore.js') + createStore('regression-store') + // a_b is valid and stored + setEntry('regression-store', 'a_b', 'value-from-underscore') + // a/b is now rejected (would have collided pre-PR-0a) + expect(() => + setEntry('regression-store', 'a/b', 'value-from-slash'), + ).toThrow(/invalid key chars/i) + // a_b still has the correct value (no overwrite happened) + expect(getEntry('regression-store', 'a_b')).toBe('value-from-underscore') + }) + + test('Windows reserved name NUL is rejected (would silently lose data on Windows)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('win-reserved') + expect(() => setEntry('win-reserved', 'NUL', 'lost')).toThrow( + /windows reserved/i, + ) + }) + + test('leading dot key is rejected (.gitconfig)', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('hidden-keys') + expect(() => setEntry('hidden-keys', '.gitconfig', 'x')).toThrow( + /leading dot/i, + ) + }) +}) + +// ── I3 / E1: Path traversal regression tests ───────────────────────────────── +// All these MUST throw BEFORE the fix lands (they test the invariant that +// invalid store names are rejected before any file I/O occurs). + +describe('multiStore: path traversal rejection (E1 regression)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-sec-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('store name ".." is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('..', 'key', 'value')).toThrow() + }) + + test('store name "a/b" is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('a/b', 'key', 'value')).toThrow() + }) + + test('store name "a\\\\b" is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('a\\b', 'key', 'value')).toThrow() + }) + + test('store name with null byte is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('foo\x00bar', 'key', 'value')).toThrow() + }) + + test('store name "C:hack" (Windows drive prefix) is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + expect(() => setEntry('C:hack', 'key', 'value')).toThrow() + }) + + test('store name that resolves outside base dir is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + // An encoded-style path that could escape + expect(() => setEntry('../escape', 'key', 'value')).toThrow() + }) + + test('store name too long (>255 chars) is rejected', async () => { + const { setEntry } = await import('../multiStore.js') + const longName = 'a'.repeat(256) + expect(() => setEntry(longName, 'key', 'value')).toThrow() + }) + + test('validateStoreName: accepted store name passes', async () => { + const { createStore } = await import('../multiStore.js') + // Should NOT throw + expect(() => createStore('valid-store-name')).not.toThrow() + }) + + test('D2: value >1MB is rejected', async () => { + const { createStore, setEntry } = await import('../multiStore.js') + createStore('size-test') + const bigValue = 'X'.repeat(1_048_577) // 1MB + 1 byte + expect(() => setEntry('size-test', 'big', bigValue)).toThrow() + }) +}) + +// ── M5 (codecov-100 audit #9): getEntryBounded short-read handling ────────── +// The audit flagged that the old loop returned a `readBytes`-sized buffer +// even if readSync delivered fewer bytes (e.g. file truncated mid-read), +// with `truncated=false`. Test pins the new behavior: short reads surface +// as `truncated=true`, and the returned value's length matches what was +// actually read (no trailing zero bytes). + +describe('multiStore: getEntryBounded short-read handling (M5 audit #9)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'multi-store-bounded-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('getEntryBounded: full read with file <= maxBytes returns truncated=false', async () => { + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'small', 'hello') + const result = getEntryBounded('bounded', 'small', 1024) + expect(result).not.toBeNull() + expect(result!.value).toBe('hello') + expect(result!.truncated).toBe(false) + }) + + test('getEntryBounded: file larger than maxBytes returns truncated=true and prefix only', async () => { + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'big', 'X'.repeat(2048)) + const result = getEntryBounded('bounded', 'big', 100) + expect(result).not.toBeNull() + expect(result!.value.length).toBe(100) + expect(result!.value).toBe('X'.repeat(100)) + expect(result!.truncated).toBe(true) + }) + + test('getEntryBounded: returned value has no trailing zero bytes (audit #9 regression)', async () => { + // The old code returned `buf.toString('utf8')` directly — if readSync + // delivered fewer bytes than the buffer was allocated for (statSync + // saw 100 bytes but only 50 were readable by readSync), the returned + // string would have 50 trailing NUL bytes () silently. The new + // code uses subarray(0, offset) so the returned string length matches + // exactly what was read. + const { createStore, setEntry, getEntryBounded } = await import( + '../multiStore.js' + ) + createStore('bounded') + setEntry('bounded', 'exact', 'a'.repeat(50)) + const result = getEntryBounded('bounded', 'exact', 100) + expect(result).not.toBeNull() + // 50-byte file, read with cap of 100 → readBytes=50, buf is 50 bytes, + // value is exactly 50 bytes with no trailing NULs. + expect(result!.value.length).toBe(50) + expect(result!.value).toBe('a'.repeat(50)) + expect(result!.value).not.toContain('') + expect(result!.truncated).toBe(false) + }) + + test('getEntryBounded: returns null for missing entry', async () => { + const { createStore, getEntryBounded } = await import('../multiStore.js') + createStore('bounded') + expect(getEntryBounded('bounded', 'missing', 1024)).toBeNull() + }) +}) diff --git a/src/services/SessionMemory/__tests__/prompts.test.ts b/src/services/SessionMemory/__tests__/prompts.test.ts new file mode 100644 index 000000000..7129a1846 --- /dev/null +++ b/src/services/SessionMemory/__tests__/prompts.test.ts @@ -0,0 +1,390 @@ +import { afterAll, describe, test, expect, mock, beforeEach } from 'bun:test' +import { homedir } from 'node:os' +import { join } from 'node:path' + +// ── Mock infrastructure ───────────────────────────────────────────────────── +// All mock.module calls must precede the import of the module under test. +// mock.module is process-global; mocks here must cover all exported names used +// transitively so sibling test files are not broken by an incomplete mock. +// +// To prevent cross-file pollution (skill prefetch / skillLearning smoke, +// model.test.ts, providers.test.ts), keep the mock surface ONLY for the +// names this suite actually exercises, and delegate to behavior that matches +// the real impl (e.g. isEnvTruthy parses '0'/'false'/'no'/'off' as falsy). +// A sentinel flag flipped in afterAll lets us scope the suite-specific +// override (mocked main-loop model, mocked effort level, fixed config dir). +let useMockForSessionMemory = true +afterAll(() => { + useMockForSessionMemory = false +}) + +const mockGetMainLoopModel = mock(() => 'claude-opus-4-7') +const mockGetDisplayedEffortLevel = mock((): string => 'high') + +const realIsEnvTruthy = (v: string | boolean | undefined): boolean => { + if (!v) return false + if (typeof v === 'boolean') return v + return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()) +} + +// Inline a minimum env-driven default-Opus resolver so getDefaultOpusModel +// .test.ts (running in the same process) sees env-precedence semantics +// after this suite's flag flips off. Keep aligned with +// src/utils/model/model.ts getDefaultOpusModel(). +function resolveDefaultOpusModelForTests(): string { + if (process.env.CLAUDE_CODE_USE_OPENAI === '1') { + if (process.env.OPENAI_DEFAULT_OPUS_MODEL) + return process.env.OPENAI_DEFAULT_OPUS_MODEL + } + if (process.env.CLAUDE_CODE_USE_GEMINI === '1') { + if (process.env.GEMINI_DEFAULT_OPUS_MODEL) + return process.env.GEMINI_DEFAULT_OPUS_MODEL + } + if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) + return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') + return 'us.anthropic.claude-opus-4-7-v1' + if (process.env.CLAUDE_CODE_USE_VERTEX === '1') return 'claude-opus-4-7' + if (process.env.CLAUDE_CODE_USE_FOUNDRY === '1') return 'claude-opus-4-7' + return 'claude-opus-4-7' +} + +// Inline the real firstPartyNameToCanonical logic so its semantics survive +// even after this suite's mock wins the registration race. Pre-importing +// model.ts hangs the test process due to heavy transitive deps. +function realFirstPartyNameToCanonical(name: string): string { + name = name.toLowerCase() + if (name.includes('claude-opus-4-7')) return 'claude-opus-4-7' + if (name.includes('claude-opus-4-6')) return 'claude-opus-4-6' + if (name.includes('claude-opus-4-5')) return 'claude-opus-4-5' + if (name.includes('claude-opus-4-1')) return 'claude-opus-4-1' + if (name.includes('claude-opus-4')) return 'claude-opus-4' + if (name.includes('claude-sonnet-4-6')) return 'claude-sonnet-4-6' + if (name.includes('claude-sonnet-4-5')) return 'claude-sonnet-4-5' + if (name.includes('claude-sonnet-4')) return 'claude-sonnet-4' + if (name.includes('claude-haiku-4-5')) return 'claude-haiku-4-5' + if (name.includes('claude-3-7-sonnet')) return 'claude-3-7-sonnet' + if (name.includes('claude-3-5-sonnet')) return 'claude-3-5-sonnet' + if (name.includes('claude-3-5-haiku')) return 'claude-3-5-haiku' + if (name.includes('claude-3-opus')) return 'claude-3-opus' + if (name.includes('claude-3-sonnet')) return 'claude-3-sonnet' + if (name.includes('claude-3-haiku')) return 'claude-3-haiku' + const m = name.match(/(claude-(\d+-\d+-)?\w+)/) + if (m && m[1]) return m[1] + return name +} + +mock.module('src/utils/model/model.js', () => ({ + getMainLoopModel: mockGetMainLoopModel, + getSmallFastModel: mock(() => 'claude-haiku'), + getUserSpecifiedModelSetting: mock(() => undefined), + getBestModel: mock(() => 'claude-opus-4-7'), + getDefaultOpusModel: mock(() => + useMockForSessionMemory + ? 'claude-opus-4-7' + : resolveDefaultOpusModelForTests(), + ), + getDefaultSonnetModel: mock(() => 'claude-sonnet-4-6'), + getDefaultHaikuModel: mock(() => 'claude-haiku-3-5'), + getRuntimeMainLoopModel: mock(() => 'claude-opus-4-7'), + getDefaultMainLoopModelSetting: mock(() => 'claude-opus-4-7'), + getDefaultMainLoopModel: mock(() => 'claude-opus-4-7'), + firstPartyNameToCanonical: mock((n: string) => + realFirstPartyNameToCanonical(n), + ), + getCanonicalName: mock((n: string) => n), + getClaudeAiUserDefaultModelDescription: mock(() => ''), + renderDefaultModelSetting: mock(() => ''), + getOpusPricingSuffix: mock(() => ''), + isOpus1mMergeEnabled: mock(() => false), + renderModelSetting: mock((s: string) => s), + getPublicModelDisplayName: mock(() => null), + renderModelName: mock((n: string) => n), + getPublicModelName: mock((n: string) => n), + parseUserSpecifiedModel: mock((m: string) => m), + resolveSkillModelOverride: mock(() => undefined), + isLegacyModelRemapEnabled: mock(() => false), + modelDisplayString: mock(() => ''), + getMarketingNameForModel: mock(() => undefined), + normalizeModelStringForAPI: mock((m: string) => m), + isNonCustomOpusModel: mock(() => false), +})) + +mock.module('src/utils/effort.js', () => ({ + getDisplayedEffortLevel: mockGetDisplayedEffortLevel as ( + _m: string, + _e: unknown, + ) => string, + getEffortEnvOverride: mock(() => undefined), + resolveAppliedEffort: mock(() => 'high'), + getInitialEffortSetting: mock(() => undefined), + parseEffortValue: mock(() => undefined), + toPersistableEffort: mock(() => undefined), + modelSupportsEffort: mock(() => true), + modelSupportsMaxEffort: mock(() => true), + modelSupportsXhighEffort: mock(() => false), + isEffortLevel: mock(() => true), + getEffortSuffix: mock(() => ''), + convertEffortValueToLevel: mock(() => 'high'), + getDefaultEffortForModel: mock(() => undefined), + getEffortLevelDescription: mock(() => ''), + getEffortValueDescription: mock(() => ''), + getOpusDefaultEffortConfig: mock(() => ({ + enabled: true, + dialogTitle: '', + dialogDescription: '', + })), + resolvePickerEffortPersistence: mock(() => undefined), + isValidNumericEffort: mock(() => false), + EFFORT_LEVELS: ['low', 'medium', 'high', 'xhigh', 'max'], +})) + +// Use REAL semantics for non-overridden envUtils exports — this mock is +// process-global, so envUtils.test.ts and other consumers running in the +// same process must see correct behavior. +const realIsEnvDefinedFalsy = (v: string | boolean | undefined): boolean => { + if (v === undefined) return false + if (typeof v === 'boolean') return !v + if (!v) return false + return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()) +} +const realDefaultVertexRegion = (): string => + process.env.CLOUD_ML_REGION || 'us-east5' +const VERTEX_REGION_OVERRIDES_SM: ReadonlyArray<[string, string]> = [ + ['claude-haiku-4-5', 'VERTEX_REGION_CLAUDE_HAIKU_4_5'], + ['claude-3-5-haiku', 'VERTEX_REGION_CLAUDE_3_5_HAIKU'], + ['claude-3-5-sonnet', 'VERTEX_REGION_CLAUDE_3_5_SONNET'], + ['claude-3-7-sonnet', 'VERTEX_REGION_CLAUDE_3_7_SONNET'], + ['claude-opus-4-1', 'VERTEX_REGION_CLAUDE_4_1_OPUS'], + ['claude-opus-4', 'VERTEX_REGION_CLAUDE_4_0_OPUS'], + ['claude-sonnet-4-6', 'VERTEX_REGION_CLAUDE_4_6_SONNET'], + ['claude-sonnet-4-5', 'VERTEX_REGION_CLAUDE_4_5_SONNET'], + ['claude-sonnet-4', 'VERTEX_REGION_CLAUDE_4_0_SONNET'], +] + +// Real getClaudeConfigHomeDir is memoized via lodash, so consumers may call +// `.cache.clear()` on it. Provide a no-op .cache stub. +const mockedGetClaudeConfigHomeDirSM: (() => string) & { + cache: { clear: () => void; get: (k: unknown) => unknown } +} = Object.assign( + () => + useMockForSessionMemory + ? '/mock/home/.claude' + : (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize( + 'NFC', + ), + { cache: { clear: () => {}, get: (_k: unknown) => undefined } }, +) + +mock.module('src/utils/envUtils.js', () => ({ + getClaudeConfigHomeDir: mockedGetClaudeConfigHomeDirSM, + isEnvTruthy: realIsEnvTruthy, + getEnvBool: () => false, + getEnvNumber: () => undefined, + getVertexRegionForModel: (model: string | undefined) => { + if (model) { + const match = VERTEX_REGION_OVERRIDES_SM.find(([prefix]) => + model.startsWith(prefix), + ) + if (match) { + return process.env[match[1]] || realDefaultVertexRegion() + } + } + return realDefaultVertexRegion() + }, + getTeamsDir: () => + join( + useMockForSessionMemory + ? '/mock/home/.claude' + : (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')), + 'teams', + ), + hasNodeOption: (flag: string) => { + const opts = process.env.NODE_OPTIONS + return !!opts && opts.split(/\s+/).includes(flag) + }, + isEnvDefinedFalsy: realIsEnvDefinedFalsy, + isBareMode: () => + realIsEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) || + process.argv.includes('--bare'), + parseEnvVars: (rawEnvArgs: string[] | undefined) => { + const parsed: Record = {} + if (rawEnvArgs) { + for (const envStr of rawEnvArgs) { + const [key, ...valueParts] = envStr.split('=') + if (!key || valueParts.length === 0) { + throw new Error( + `Invalid environment variable format: ${envStr}, environment variables should be added as: -e KEY1=value1 -e KEY2=value2`, + ) + } + parsed[key] = valueParts.join('=') + } + } + return parsed + }, + getAWSRegion: () => + process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1', + getDefaultVertexRegion: realDefaultVertexRegion, + shouldMaintainProjectWorkingDir: () => + realIsEnvTruthy(process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR), + isRunningOnHomespace: () => + process.env.USER_TYPE === 'ant' && + realIsEnvTruthy(process.env.COO_RUNNING_ON_HOMESPACE), + isInProtectedNamespace: () => false, +})) + +mock.module('src/utils/log.js', () => ({ + logError: mock(() => {}), + getLogDisplayTitle: mock(() => ''), + dateToFilename: mock((d: Date) => d.toISOString()), + attachErrorLogSink: mock(() => {}), + getInMemoryErrors: mock(() => []), + loadErrorLogs: mock(async () => []), + getErrorLogByIndex: mock(async () => null), + logMCPError: mock(() => {}), + logMCPDebug: mock(() => {}), + captureAPIRequest: mock(() => {}), + _resetErrorLogForTesting: mock(() => {}), +})) + +mock.module('src/services/tokenEstimation.js', () => ({ + roughTokenCountEstimation: mock((s: string) => Math.ceil(s.length / 4)), + countTokens: mock(async () => 0), +})) + +mock.module('src/utils/errors.js', () => ({ + getErrnoCode: mock((e: unknown) => (e as NodeJS.ErrnoException)?.code), + toError: mock((e: unknown) => + e instanceof Error ? e : new Error(String(e)), + ), +})) + +// Mock fs/promises so loadSessionMemoryPrompt() and loadSessionMemoryTemplate() +// return our controlled templates. Once afterAll flips +// useMockForSessionMemory off, readFile delegates to the real impl so +// sibling tests in the same process (skill prefetch, skillLearning smoke) +// still see real disk reads. We must list every export the prefetch / +// skillLearning paths use so this process-global mock doesn't strip names +// to undefined. +// +// Instead of pre-importing node:fs/promises (which can interact poorly +// with bun:test mock processing), use require() at mock-factory-call time +// to fetch the real module lazily. +const mockReadFileFsPromises = mock( + async (_path: string, _opts?: unknown): Promise => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }, +) + +mock.module('fs/promises', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const real = require('node:fs/promises') as Record + return { + ...real, + readFile: ((path: unknown, opts?: unknown) => { + if (useMockForSessionMemory) { + return mockReadFileFsPromises(path as string, opts) + } + return (real.readFile as (...a: unknown[]) => unknown)( + path as string, + opts, + ) + }) as typeof real.readFile, + } +}) + +// ── Import module under test (after all mock.module calls) ────────────────── +import { buildSessionMemoryUpdatePrompt } from '../prompts.js' + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('buildSessionMemoryUpdatePrompt – dynamic variable substitution', () => { + beforeEach(() => { + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + mockGetDisplayedEffortLevel.mockReturnValue('high') + // Default: ENOENT so the built-in default prompt is used + mockReadFileFsPromises.mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + }) + + test('substitutes {{CLAUDE_MODEL}} with the current model', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'Model: {{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('Model: claude-opus-4-7') + expect(result).not.toContain('{{CLAUDE_MODEL}}') + }) + + test('substitutes {{CLAUDE_EFFORT}} with the current effort level', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'Effort: {{CLAUDE_EFFORT}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetDisplayedEffortLevel.mockReturnValue('high') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('Effort: high') + expect(result).not.toContain('{{CLAUDE_EFFORT}}') + }) + + test('substitutes {{CLAUDE_CWD}} with process.cwd()', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) return 'CWD: {{CLAUDE_CWD}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain(`CWD: ${process.cwd()}`) + expect(result).not.toContain('{{CLAUDE_CWD}}') + }) + + test('substitutes all three dynamic variables in one template', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return 'effort={{CLAUDE_EFFORT}} model={{CLAUDE_MODEL}} cwd={{CLAUDE_CWD}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-sonnet-4-6') + mockGetDisplayedEffortLevel.mockReturnValue('medium') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('effort=medium') + expect(result).toContain('model=claude-sonnet-4-6') + expect(result).toContain(`cwd=${process.cwd()}`) + }) + + test('leaves unknown template variables unchanged', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return '{{UNKNOWN_VAR}} {{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-opus-4-7') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('{{UNKNOWN_VAR}}') + expect(result).toContain('claude-opus-4-7') + }) + + test('existing substitution variables still work alongside new ones', async () => { + mockReadFileFsPromises.mockImplementation(async (path: string) => { + if ((path as string).includes('prompt.md')) + return '{{notesPath}} effort={{CLAUDE_EFFORT}} model={{CLAUDE_MODEL}}' + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + mockGetMainLoopModel.mockReturnValue('claude-haiku') + mockGetDisplayedEffortLevel.mockReturnValue('low') + + const result = await buildSessionMemoryUpdatePrompt('notes', '/notes.md') + expect(result).toContain('/notes.md') + expect(result).toContain('effort=low') + expect(result).toContain('model=claude-haiku') + }) +}) diff --git a/src/services/SessionMemory/prompts.ts b/src/services/SessionMemory/prompts.ts index e220736f9..e67904249 100644 --- a/src/services/SessionMemory/prompts.ts +++ b/src/services/SessionMemory/prompts.ts @@ -4,6 +4,8 @@ import { roughTokenCountEstimation } from '../../services/tokenEstimation.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getErrnoCode, toError } from '../../utils/errors.js' import { logError } from '../../utils/log.js' +import { getDisplayedEffortLevel } from '../../utils/effort.js' +import { getMainLoopModel } from '../../utils/model/model.js' const MAX_SECTION_LENGTH = 2000 const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000 @@ -235,9 +237,13 @@ export async function buildSessionMemoryUpdatePrompt( const sectionReminders = generateSectionReminders(sectionSizes, totalTokens) // Substitute variables in the prompt + const currentModel = getMainLoopModel() const variables = { currentNotes, notesPath, + CLAUDE_EFFORT: getDisplayedEffortLevel(currentModel, undefined), + CLAUDE_MODEL: currentModel, + CLAUDE_CWD: process.cwd(), } const basePrompt = substituteVariables(promptTemplate, variables)