v1.12 track A: container guidance + skills
This commit is contained in:
@@ -73,26 +73,26 @@ function makeMessage(
|
||||
|
||||
// ---- tests ------------------------------------------------------------------
|
||||
|
||||
describe('buildMessagesPayload', () => {
|
||||
it('prepends a system prompt containing the project path', () => {
|
||||
describe('buildMessagesPayload', async () => {
|
||||
it('prepends a system prompt containing the project path', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject({ path: '/tmp/my-proj' });
|
||||
const result = buildMessagesPayload(session, project, []);
|
||||
const result = await buildMessagesPayload(session, project, []);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[0]!.content).toContain('/tmp/my-proj');
|
||||
});
|
||||
|
||||
it('appends session.system_prompt to the system message when set', () => {
|
||||
it('appends session.system_prompt to the system message when set', async () => {
|
||||
const session = makeSession({ system_prompt: 'Be terse.' });
|
||||
const project = makeProject();
|
||||
const result = buildMessagesPayload(session, project, []);
|
||||
const result = await buildMessagesPayload(session, project, []);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
expect(result[0]!.content).toContain('Be terse.');
|
||||
});
|
||||
|
||||
it('returns user/assistant messages in order when no compact marker is present', () => {
|
||||
it('returns user/assistant messages in order when no compact marker is present', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
@@ -101,7 +101,7 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('user', 'how are you'),
|
||||
makeMessage('assistant', 'great'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// 1 system + 4 history messages
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]!.role).toBe('system');
|
||||
@@ -111,7 +111,7 @@ describe('buildMessagesPayload', () => {
|
||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
|
||||
});
|
||||
|
||||
it('starts from the latest compact marker, emitting it as a system message', () => {
|
||||
it('starts from the latest compact marker, emitting it as a system message', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
@@ -122,7 +122,7 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('user', 'new1'),
|
||||
makeMessage('assistant', 'newreply1'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// Expect: leading base-system prompt, then the compact as system, then
|
||||
// the user/assistant pair following it.
|
||||
expect(result).toHaveLength(4);
|
||||
@@ -135,7 +135,7 @@ describe('buildMessagesPayload', () => {
|
||||
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
|
||||
});
|
||||
|
||||
it('uses only the most recent compact when multiple are present', () => {
|
||||
it('uses only the most recent compact when multiple are present', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
@@ -146,7 +146,7 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('user', 'u3'),
|
||||
makeMessage('assistant', 'final reply'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// Expect: base system + latest compact as system + the two messages
|
||||
// following it. The earlier compact and pre-compact history are dropped.
|
||||
expect(result).toHaveLength(4);
|
||||
@@ -164,7 +164,7 @@ describe('buildMessagesPayload', () => {
|
||||
expect(concatenated).not.toContain('u2');
|
||||
});
|
||||
|
||||
it('skips streaming and cancelled assistant rows', () => {
|
||||
it('skips streaming and cancelled assistant rows', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
@@ -173,14 +173,14 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
|
||||
makeMessage('assistant', 'final answer'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant (only the complete one)
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||
});
|
||||
|
||||
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
|
||||
it('round-trips an assistant-with-tool_calls followed by its tool result', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const toolCall: ToolCall = {
|
||||
@@ -199,7 +199,7 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('tool', '', { tool_results: toolResult }),
|
||||
makeMessage('assistant', 'here it is'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
|
||||
@@ -226,7 +226,7 @@ describe('buildMessagesPayload', () => {
|
||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
||||
});
|
||||
|
||||
it('skips tool rows with no tool_results', () => {
|
||||
it('skips tool rows with no tool_results', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const history: Message[] = [
|
||||
@@ -234,7 +234,7 @@ describe('buildMessagesPayload', () => {
|
||||
makeMessage('tool', '', { tool_results: null }),
|
||||
makeMessage('assistant', 'done'),
|
||||
];
|
||||
const result = buildMessagesPayload(session, project, history);
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
||||
|
||||
178
apps/server/src/services/__tests__/system-prompt.test.ts
Normal file
178
apps/server/src/services/__tests__/system-prompt.test.ts
Normal 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 ---');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user