import { describe, expect, it } from 'vitest' import { extractLabelFacts } from '../labels/label-schema.js' import type { GiteaIssue } from '../gitea/types.js' import type { FetchLike } from '../gitea/types.js' import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js' import { runAgentTurn } from './agent-loop.js' import { pickModel } from './model-router.js' import { buildProjectView, type ProjectSnapshot } from './query-project.js' // ---- chat client ---- describe('createChatClient', () => { function stub(body: unknown) { const calls: { url: string; body: unknown }[] = [] const fetch: FetchLike = (url, init) => { calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve(''), }) } return { fetch, calls } } it('POSTs to /chat/completions and parses content', async () => { const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] }) const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch) const res = await client.complete([{ role: 'user', content: 'what now?' }]) expect(res.content).toBe('the focus is #2') expect(res.toolCalls).toEqual([]) expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions') expect((calls[0].body as { model: string }).model).toBe('gemma') }) it('parses tool calls from the wire shape', async () => { const { fetch } = stub({ choices: [ { message: { content: null, tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }], }, }, ], }) const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch) const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }]) expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }]) }) it('serializes assistant tool_calls + tool results in the request', async () => { const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] }) const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch) const convo: ChatMessage[] = [ { role: 'user', content: 'hi' }, { role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] }, { role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' }, ] await client.complete(convo) const sent = (calls[0].body as { messages: Record[] }).messages expect(sent[1].tool_calls).toBeDefined() expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' }) }) }) // ---- model router ---- describe('pickModel', () => { const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } } it('routes plan → big, everything else → small', () => { expect(pickModel(router, 'plan').model).toBe('big') expect(pickModel(router, 'ritual').model).toBe('small') }) }) // ---- agent loop ---- describe('runAgentTurn', () => { const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }] it('runs a tool then returns the model prose, recording the step', async () => { // first completion asks for a tool; second answers in prose const scripted: CompletionResult[] = [ { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] }, { content: 'Right now: #2.', toolCalls: [] }, ] let i = 0 const executed: { name: string; args: unknown }[] = [] const turn = await runAgentTurn({ complete: async () => scripted[i++], messages: [{ role: 'user', content: 'what now?' }], tools, execute: async (name, args) => { executed.push({ name, args }) return { now: { issue: 2 } } }, }) expect(turn.content).toBe('Right now: #2.') expect(turn.steps).toEqual([ { tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' }, ]) expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }]) // conversation carries user → assistant(tool) → tool → assistant(prose) expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']) }) it('answers directly when the model needs no tool', async () => { const turn = await runAgentTurn({ complete: async () => ({ content: 'Hello.', toolCalls: [] }), messages: [{ role: 'user', content: 'hi' }], tools, execute: async () => ({}), }) expect(turn.content).toBe('Hello.') expect(turn.steps).toEqual([]) }) it('captures a tool executor error as the tool result instead of throwing', async () => { const scripted: CompletionResult[] = [ { content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] }, { content: 'Something went wrong reading that.', toolCalls: [] }, ] let i = 0 const turn = await runAgentTurn({ complete: async () => scripted[i++], messages: [{ role: 'user', content: 'x' }], tools, execute: async () => { throw new Error('boom') }, }) expect(turn.steps[0].result).toContain('boom') expect(turn.content).toBe('Something went wrong reading that.') }) it('forces a final prose answer when the step budget is exhausted', async () => { // always asks for a tool; the loop must still terminate with prose let toolRounds = 0 const turn = await runAgentTurn({ complete: async (_m, tls) => { if (tls && tls.length) { toolRounds++ return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] } } return { content: 'final answer', toolCalls: [] } // called with tools withheld }, messages: [{ role: 'user', content: 'x' }], tools, execute: async () => ({}), maxSteps: 2, }) expect(toolRounds).toBe(2) expect(turn.content).toBe('final answer') }) }) // ---- query_project view builder ---- describe('buildProjectView', () => { const asOf = new Date('2026-02-01T00:00:00Z') function issue(over: Partial): GiteaIssue { const labels = over.labels ?? [] return { number: 1, title: '#1', body: '', state: 'open', labels, facts: extractLabelFacts(labels), milestone: null, assignee: null, assignees: [], createdAt: '2026-01-05T09:00:00Z', updatedAt: '2026-01-05T09:00:00Z', closedAt: null, url: '', ...over, } } const snap: ProjectSnapshot = { issues: [ issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }), issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }), issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }), ], timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] }, deps: [{ issue: 3, dependsOn: 2 }], } it('focus returns Now/Next/Later grounded in the scheduler', () => { const v = buildProjectView('focus', undefined, snap, asOf) as { now: { issue: number } | null openCount: number } expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first expect(v.openCount).toBe(2) }) it('board groups issues by lifecycle column', () => { const v = buildProjectView('board', undefined, snap, asOf) as { columns: { column: string; count: number }[] } const done = v.columns.find((c) => c.column === 'done') expect(done?.count).toBe(1) // #9 const triage = v.columns.find((c) => c.column === 'triage') expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet }) it('calibration reports n + cold-start honestly', () => { const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean } expect(v.n).toBe(1) // one closed, estimated, with an actual expect(v.coldStart).toBe(true) }) it('issue returns intent + derived for a specific id', () => { const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as { issue: number blockedBy: number[] } expect(v.issue).toBe(3) expect(v.blockedBy).toEqual([2]) }) it('search matches title/labels', () => { const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] } expect(v.results.map((r) => r.issue)).toEqual([3]) }) it('unbuilt views return a notImplemented marker, not fabricated data', () => { expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' }) }) })