feat: Reginald is real — model router + agent loop + query_project (P4)
The fixture chat panel is now a working agent. Ask Reginald a question and it consults the real project through a tool loop, then answers in grounded prose. Read-only v0 — writes still go through the propose-approve controls. core (@commitea/core/agent): - chat-client: OpenAI-wire chat completions over an injected fetch (same seam as gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI). - model-router: small model for prose + the read tool; big model reserved for later decomposition (pickModel). - agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step budget), recording each tool step. Injected complete + execute → fully testable. - query-project: the single read tool's engine — compact focus/board/calibration/ issue/search views built from scheduler + lifecycle + calibration; unbuilt views return a notImplemented marker (never fabricated). The model reports, never computes. - agent-tools: query_project declaration + Reginald's system prompt. app: - main model bridge (model:status, model:chat) runs the loop; query_project reconciles the repo and builds the view. Model traffic stays in main (token/CSP). gitea.ts refactored to share getGiteaClient + reconcileSnapshot. - preload + global.d.ts expose the model bridge; useChat drives the panel — real agent turn when a model is configured, scripted fixture reply otherwise (so fixture e2e is unchanged). A subtle "consulted the project" activity line. Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL / _SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps it unconfigured so the panel stays scripted. Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget, all views) + a gated live integration test. Desktop typecheck clean, 14 fixture e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked "what now?", Reginald called query_project and answered "focus is on issue #2" (the real scheduler pick). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
239
packages/core/src/agent/agent.test.ts
Normal file
239
packages/core/src/agent/agent.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
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<string, unknown>[] }).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>): 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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user