import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, writeFile, rm, utimes } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { loadContainerGuidance, getContainerGuidance, buildSystemPrompt, buildSystemPromptWithFingerprint, _resetContainerGuidanceCacheForTests, _resetPrefixObserverForTests, } from '../system-prompt.js'; import type { Agent, Project, Session } from '../../types/api.js'; // ---- fixtures --------------------------------------------------------------- let tmpDir: string; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-')); _resetContainerGuidanceCacheForTests(); _resetPrefixObserverForTests(); delete process.env['CONTAINER_GUIDANCE_FILE']; }); afterEach(async () => { delete process.env['CONTAINER_GUIDANCE_FILE']; _resetContainerGuidanceCacheForTests(); _resetPrefixObserverForTests(); await rm(tmpDir, { recursive: true, force: true }); }); function makeSession(overrides: Partial = {}): Session { return { id: 'sess', project_id: 'proj', name: 'test session', model: 'test-model', system_prompt: '', status: 'open', created_at: new Date(0).toISOString(), updated_at: new Date(0).toISOString(), agent_id: null, web_search_enabled: null, ...overrides, }; } function makeProject(overrides: Partial = {}): Project { return { id: 'proj', name: 'test project', path: '/tmp/proj', added_at: new Date(0).toISOString(), last_session_id: null, status: 'open', gitea_remote: null, default_system_prompt: '', default_web_search_enabled: false, ...overrides, }; } function makeAgent(overrides: Partial = {}): Agent { return { id: 'agent-foo', name: 'foo', description: 'test agent', system_prompt: 'Speak in haiku.', temperature: 0.3, tools: ['view_file'], model: null, source: 'global', max_tool_calls: null, ...overrides, }; } // ---- tests ------------------------------------------------------------------ describe('loadContainerGuidance', () => { it('returns file content when CONTAINER_GUIDANCE_FILE points to an existing file', async () => { const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'hello from BOOCHAT', 'utf8'); process.env['CONTAINER_GUIDANCE_FILE'] = path; const result = await loadContainerGuidance(); expect(result).toBe('hello from BOOCHAT'); }); it('returns null when the env var points to a non-existent file', async () => { process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'does-not-exist.md'); const result = await loadContainerGuidance(); expect(result).toBeNull(); }); it('returns null when the env var is unset and /app/BOOCHAT.md does not exist', async () => { // env var deleted in beforeEach; /app/BOOCHAT.md doesn't exist on the // host (the prod path only resolves inside the container). const result = await loadContainerGuidance(); expect(result).toBeNull(); }); }); describe('getContainerGuidance (mtime-watch cache)', () => { it('caches the content across calls when the file mtime is unchanged', async () => { const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'first content', 'utf8'); // Pin mtime to a known Date BEFORE the first call so we can restore it // exactly after the rewrite. Capturing s.mtime then writing+restoring is // unreliable because Date round-trips truncate sub-millisecond precision // that the filesystem reports back via stat.mtimeMs. const fixedTime = new Date(2020, 0, 1, 12, 0, 0); await utimes(path, fixedTime, fixedTime); process.env['CONTAINER_GUIDANCE_FILE'] = path; const first = await getContainerGuidance(); expect(first).toBe('first content'); // Rewrite the file with different content, then restore mtime to the // same fixedTime. The cache must NOT re-read because the stat is // unchanged from its point of view. await writeFile(path, 'NEW content the cache must NOT see', 'utf8'); await utimes(path, fixedTime, fixedTime); const second = await getContainerGuidance(); expect(second).toBe('first content'); }); it('re-reads the file when the mtime changes', async () => { const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'first content', 'utf8'); process.env['CONTAINER_GUIDANCE_FILE'] = path; const first = await getContainerGuidance(); expect(first).toBe('first content'); // Bump mtime explicitly so the test doesn't race the filesystem's mtime // resolution. Future time → guaranteed different from the cached value. await writeFile(path, 'edited content', 'utf8'); const later = new Date(Date.now() + 60_000); await utimes(path, later, later); const second = await getContainerGuidance(); expect(second).toBe('edited content'); }); }); describe('buildSystemPrompt', () => { it('includes the guidance block between the base prompt and the agent overlay when guidance is non-null', async () => { const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'CONTAINER RULES GO HERE', 'utf8'); process.env['CONTAINER_GUIDANCE_FILE'] = path; const session = makeSession(); const project = makeProject({ path: '/tmp/test-proj' }); const agent = makeAgent({ system_prompt: 'Speak in haiku.' }); const prompt = await buildSystemPrompt(project, session, agent); const baseIdx = prompt.indexOf('/tmp/test-proj'); const guidanceIdx = prompt.indexOf('CONTAINER RULES GO HERE'); const agentIdx = prompt.indexOf('Speak in haiku.'); expect(baseIdx).toBeGreaterThanOrEqual(0); expect(guidanceIdx).toBeGreaterThan(baseIdx); expect(agentIdx).toBeGreaterThan(guidanceIdx); expect(prompt).toContain('--- Container guidance ---'); expect(prompt).toContain('--- end container guidance ---'); }); it('omits the guidance block entirely (no delimiters) when guidance is null', async () => { // Env var points to a non-existent file → getContainerGuidance returns null. process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'never-existed.md'); const session = makeSession(); const project = makeProject({ path: '/tmp/test-proj' }); const prompt = await buildSystemPrompt(project, session, null); expect(prompt).toContain('/tmp/test-proj'); expect(prompt).not.toContain('--- Container guidance ---'); expect(prompt).not.toContain('--- end container guidance ---'); }); }); // v1.13.8: byte-stability instrumentation surface. describe('buildSystemPromptWithFingerprint (v1.13.8)', () => { it('returns byte-identical prompts for two consecutive calls with the same inputs', async () => { const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'stable guidance', 'utf8'); process.env['CONTAINER_GUIDANCE_FILE'] = path; const session = makeSession(); const project = makeProject({ path: '/tmp/stable-proj' }); const agent = makeAgent({ system_prompt: 'be terse' }); const first = await buildSystemPromptWithFingerprint(project, session, agent); const second = await buildSystemPromptWithFingerprint(project, session, agent); expect(first.prompt).toBe(second.prompt); expect(first.fingerprint.prefix_hash).toBe(second.fingerprint.prefix_hash); expect(first.fingerprint.prefix_length).toBe(second.fingerprint.prefix_length); }); it('emits drift=null on the first call for a fresh session, then null again when nothing changes', async () => { process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md'); const session = makeSession(); const project = makeProject({ path: '/tmp/stable-proj' }); const first = await buildSystemPromptWithFingerprint(project, session, null); expect(first.drift).toBeNull(); const second = await buildSystemPromptWithFingerprint(project, session, null); expect(second.drift).toBeNull(); expect(second.fingerprint.prefix_hash).toBe(first.fingerprint.prefix_hash); }); it('emits drift with prev/new hashes and a changed_inputs entry when an input mutates', async () => { // Two BOOCHAT.md contents with different mtimes → guidance cache picks // up the change → fingerprint hash flips → drift fires. const path = join(tmpDir, 'BOOCHAT.md'); await writeFile(path, 'first', 'utf8'); process.env['CONTAINER_GUIDANCE_FILE'] = path; const session = makeSession(); const project = makeProject({ path: '/tmp/stable-proj' }); const first = await buildSystemPromptWithFingerprint(project, session, null); expect(first.drift).toBeNull(); await writeFile(path, 'second — different content', 'utf8'); const later = new Date(Date.now() + 60_000); await utimes(path, later, later); const second = await buildSystemPromptWithFingerprint(project, session, null); expect(second.drift).not.toBeNull(); expect(second.drift!.prev_hash).toBe(first.fingerprint.prefix_hash); expect(second.drift!.new_hash).toBe(second.fingerprint.prefix_hash); expect(second.drift!.prev_hash).not.toBe(second.drift!.new_hash); expect(second.drift!.changed_inputs).toContain('mtime_boochat'); }); it('does not fire drift across distinct sessions even if their hashes differ', async () => { process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md'); const sessionA = makeSession({ id: 'sess-A' }); const sessionB = makeSession({ id: 'sess-B', system_prompt: 'B-only override' }); const project = makeProject({ path: '/tmp/stable-proj' }); const a = await buildSystemPromptWithFingerprint(project, sessionA, null); const b = await buildSystemPromptWithFingerprint(project, sessionB, null); expect(a.drift).toBeNull(); expect(b.drift).toBeNull(); expect(a.fingerprint.prefix_hash).not.toBe(b.fingerprint.prefix_hash); }); });