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, _resetContainerGuidanceCacheForTests, } 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(); delete process.env['CONTAINER_GUIDANCE_FILE']; }); afterEach(async () => { delete process.env['CONTAINER_GUIDANCE_FILE']; _resetContainerGuidanceCacheForTests(); 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 ---'); }); });