Files
boocode/apps/server/src/services/__tests__/inference.test.ts
indifferentketchup 9ef00c0268 v1.12.4: complete inference.ts split into services/inference/
- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel,
  runDoomLoopSummary, insertDoomLoopSentinel
- inference.ts → inference/turn.ts: residue is runAssistantTurn,
  runInference, createInferenceRunner orchestration only
- inference/index.ts: re-export shim preserves the public surface
  (createInferenceRunner, runInference, runAssistantTurn,
  detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus
  type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/
  FramePublisher)
- src/index.ts + auto_name.ts + the two vitest test files updated to
  import from ./services/inference/index.js explicitly (NodeNext ESM
  doesn't honor directory-index resolution)

Final tally: 11 files under services/inference/, the largest being
sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept
side-by-side until a third sentinel justifies factoring out a shared
runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is
stream-phase.ts at 380. Public import surface unchanged.

tool-phase.ts → turn.ts back-edge for runAssistantTurn remains
(cycle is safe; resolved at call time).

Prepares the file structure for v1.13 AI SDK migration — streamText
swap targets stream-phase.ts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:36:35 +00:00

243 lines
8.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { buildMessagesPayload } from '../inference/index.js';
import type {
Message,
MessageRole,
Project,
Session,
ToolCall,
ToolResult,
} from '../../types/api.js';
// ---- fixtures ---------------------------------------------------------------
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,
};
}
let counter = 0;
function makeMessage(
role: MessageRole,
content: string,
overrides: Partial<Message> = {}
): Message {
counter += 1;
return {
id: `m${counter}`,
session_id: 'sess',
chat_id: 'chat',
role,
content,
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'complete',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date(counter * 1000).toISOString(),
metadata: null,
...overrides,
};
}
// ---- tests ------------------------------------------------------------------
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 = 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', async () => {
const session = makeSession({ system_prompt: 'Be terse.' });
const project = makeProject();
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', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'hello'),
makeMessage('user', 'how are you'),
makeMessage('assistant', 'great'),
];
const result = await buildMessagesPayload(session, project, history);
// 1 system + 4 history messages
expect(result).toHaveLength(5);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
});
it('starts from the latest compact marker, emitting it as a system message', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'old1'),
makeMessage('assistant', 'oldreply1'),
makeMessage('user', 'old2'),
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
makeMessage('user', 'new1'),
makeMessage('assistant', 'newreply1'),
];
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);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'compacted summary text',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
});
it('uses only the most recent compact when multiple are present', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'u1'),
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
makeMessage('user', 'u2'),
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
makeMessage('user', 'u3'),
makeMessage('assistant', 'final reply'),
];
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);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'second compact summary',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
// None of the earlier content should leak through
const concatenated = result.map((m) => m.content ?? '').join(' ');
expect(concatenated).not.toContain('first compact summary');
expect(concatenated).not.toContain('u1');
expect(concatenated).not.toContain('u2');
});
it('skips streaming and cancelled assistant rows', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'partial...', { status: 'streaming' }),
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
makeMessage('assistant', 'final answer'),
];
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', async () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
id: 'call_abc',
name: 'view_file',
args: { path: 'src/index.ts' },
};
const toolResult: ToolResult = {
tool_call_id: 'call_abc',
output: { contents: 'console.log(1)' },
truncated: false,
};
const history: Message[] = [
makeMessage('user', 'show me the file'),
makeMessage('assistant', '', { tool_calls: [toolCall] }),
makeMessage('tool', '', { tool_results: toolResult }),
makeMessage('assistant', 'here it is'),
];
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' });
expect(result[2]!.role).toBe('assistant');
expect(result[2]!.tool_calls).toBeDefined();
expect(result[2]!.tool_calls).toHaveLength(1);
expect(result[2]!.tool_calls![0]).toMatchObject({
id: 'call_abc',
type: 'function',
function: { name: 'view_file' },
});
// The OpenAI shape stringifies args.
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
JSON.stringify({ path: 'src/index.ts' })
);
// assistant with empty content should be serialized as content: null
expect(result[2]!.content).toBeNull();
expect(result[3]).toMatchObject({
role: 'tool',
tool_call_id: 'call_abc',
});
// Non-string tool output is JSON-stringified.
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
});
it('skips tool rows with no tool_results', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'do it'),
makeMessage('tool', '', { tool_results: null }),
makeMessage('assistant', 'done'),
];
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();
});
});