Recon during planning disproved the original v1.13.7 (DB-cache) premise: buildSystemPrompt already runs over inputs mtime-cached at the file layer (BOOCHAT.md in system-prompt.ts:25, AGENTS.md global+per-project in agents.ts:245), and DB scalars are byte-stable until edited. The output is microsecond pure-string concat with no I/O. Skills aren't in the prefix; tools live in a separate request body field alpha-sorted by v1.13.3. This batch closes the verification gap with instrumentation, not implementation: - system-prompt.ts: buildSystemPromptWithFingerprint canonical impl computes SHA-256 over the assembled prefix, runs a per-session Map<sessionId, lastHash> observer, emits PrefixFingerprint per call and PrefixDrift (with field-level changed_inputs) on hash change. buildSystemPrompt is now a thin shim returning .prompt. - agents.ts: getAgentsMtimes accessor — cache-read only, no I/O. - payload.ts: buildMessagesPayload takes optional log argument; when passed, emits prefix-fingerprint (info) + prefix-drift (warn). - turn.ts + sentinel-summaries.ts: pass ctx.log at 3 production call sites; sentinel summaries log too so any drift across cap-hit / doom-loop paths surfaces. - system-prompt.test.ts: 4 new tests (byte-identical, no-drift-on- stable, drift-fires-with-changed-inputs, cross-session-no-drift). 194/194 tests pass (was 190). Smoke: 5 messages in a fresh session produced 7 prefix-fingerprint logs (extras from buildMessagesPayload being called from sentinel summary paths), all with identical prefix_hash and prefix_length=2907, zero prefix-drift. Prefix is byte-stable in steady-state. Decision: original system_prompt_cache DB table from the roadmap is permanently dropped. The v1.12.0 mtime caches at the input layer plus alpha tool ordering at the request body (v1.13.3) already address the load-bearing cache-stability surfaces. Instrumentation stays so the claim can be re-verified at any time.
255 lines
9.8 KiB
TypeScript
255 lines
9.8 KiB
TypeScript
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> = {}): 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> = {}): 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> = {}): 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);
|
|
});
|
|
});
|