- 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>
243 lines
8.7 KiB
TypeScript
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();
|
|
});
|
|
});
|