import { describe, it, expect } from 'vitest'; import { buildMessagesPayload } from '../inference.js'; import type { Message, MessageRole, Project, Session, ToolCall, ToolResult, } from '../../types/api.js'; // ---- fixtures --------------------------------------------------------------- function makeSession(overrides: Partial = {}): 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 { 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 { 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', () => { it('prepends a system prompt containing the project path', () => { const session = makeSession(); const project = makeProject({ path: '/tmp/my-proj' }); const result = 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', () => { const session = makeSession({ system_prompt: 'Be terse.' }); const project = makeProject(); const result = 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', () => { 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 = 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', () => { 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 = 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', () => { 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 = 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', () => { 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 = 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', () => { 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 = 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', () => { const session = makeSession(); const project = makeProject(); const history: Message[] = [ makeMessage('user', 'do it'), makeMessage('tool', '', { tool_results: null }), makeMessage('assistant', 'done'), ]; const result = 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(); }); });