v1.12 track A: container guidance + skills

This commit is contained in:
2026-05-21 15:11:04 +00:00
parent ab01e04d77
commit a2e2481ef9
11 changed files with 482 additions and 79 deletions

View File

@@ -0,0 +1,178 @@
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> = {}): 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 ---');
});
});