feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean). wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes. openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { resolveGatewayModel } from '../local-gateway.js';
|
|
import { prefixBoocodeLocalModels, clearProviderSnapshotCache, getProviderSnapshot } from '../provider-snapshot.js';
|
|
import { loadLlamaProviders } from '../llama-providers.js';
|
|
import { loadProviderConfig } from '../provider-config-registry.js';
|
|
|
|
vi.mock('../acp-probe.js', () => ({
|
|
probeAcpProvider: vi.fn(),
|
|
}));
|
|
import { probeAcpProvider } from '../acp-probe.js';
|
|
const mockProbe = vi.mocked(probeAcpProvider);
|
|
|
|
/** Load a providers fixture into the in-memory registry. */
|
|
function loadProvidersFixture(providers: Array<{ id: string; label: string; baseUrl: string; kind?: string }>): void {
|
|
const file = {
|
|
defaultProvider: providers[0]?.id ?? 'llama-swap',
|
|
providers,
|
|
};
|
|
const path = join(tmpdir(), `llama-providers-w7-${Date.now()}.json`);
|
|
writeFileSync(path, JSON.stringify(file), 'utf8');
|
|
loadLlamaProviders(path, 'http://localhost:8080');
|
|
}
|
|
|
|
function mockSql(agents: Array<{
|
|
name: string;
|
|
install_path: string | null;
|
|
supports_acp: boolean;
|
|
models: Array<{ id: string; label: string }> | null;
|
|
label: string | null;
|
|
transport: string | null;
|
|
last_probed_at?: string | null;
|
|
}>) {
|
|
return vi.fn((strings: TemplateStringsArray) => {
|
|
const query = strings.join('');
|
|
if (query.includes('FROM available_agents')) {
|
|
return Promise.resolve(agents);
|
|
}
|
|
if (query.includes('UPDATE available_agents')) {
|
|
return Promise.resolve([]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as unknown as import('../db.js').Sql;
|
|
}
|
|
|
|
// --- Gateway model-id parsing tests ---
|
|
|
|
describe('resolveGatewayModel', () => {
|
|
beforeEach(() => {
|
|
loadProvidersFixture([
|
|
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
|
|
{ id: 'embedding', label: 'Embedding', baseUrl: 'http://100.90.172.55:8411' },
|
|
]);
|
|
});
|
|
|
|
it('resolves composite "provider/model" to the correct baseUrl', () => {
|
|
const result = resolveGatewayModel('sam-desktop/qwen3.6-35b');
|
|
expect(result).toEqual({
|
|
baseUrl: 'http://100.101.41.16:8401',
|
|
wireModelId: 'qwen3.6-35b',
|
|
});
|
|
});
|
|
|
|
it('resolves a different provider to its own baseUrl', () => {
|
|
const result = resolveGatewayModel('embedding/gemma-4-12b');
|
|
expect(result).toEqual({
|
|
baseUrl: 'http://100.90.172.55:8411',
|
|
wireModelId: 'gemma-4-12b',
|
|
});
|
|
});
|
|
|
|
it('returns error for unknown provider', () => {
|
|
const result = resolveGatewayModel('nonexistent/model');
|
|
expect(result).toHaveProperty('error');
|
|
expect((result as { error: string }).error).toContain('unknown provider');
|
|
});
|
|
|
|
it('bare model resolves to default provider', () => {
|
|
const result = resolveGatewayModel('qwen3.6-35b');
|
|
expect(result).toEqual({
|
|
baseUrl: 'http://100.101.41.16:8401',
|
|
wireModelId: 'qwen3.6-35b',
|
|
});
|
|
});
|
|
|
|
it('two providers serving the SAME wire model name hit different baseUrls', () => {
|
|
const r1 = resolveGatewayModel('sam-desktop/qwen3.6-35b');
|
|
const r2 = resolveGatewayModel('embedding/qwen3.6-35b');
|
|
expect(r1).toHaveProperty('baseUrl', 'http://100.101.41.16:8401');
|
|
expect(r2).toHaveProperty('baseUrl', 'http://100.90.172.55:8411');
|
|
expect((r1 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
|
|
expect((r2 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
|
|
});
|
|
});
|
|
|
|
// --- prefixBoocodeLocalModels ---
|
|
|
|
describe('prefixBoocodeLocalModels', () => {
|
|
it('wraps composite ids with boocode-local prefix', () => {
|
|
const result = prefixBoocodeLocalModels([
|
|
{ id: 'sam-desktop/qwen3.6-35b', label: 'Qwen' },
|
|
{ id: 'embedding/gemma-4-12b', label: 'Gemma' },
|
|
]);
|
|
expect(result.map((m) => m.id)).toEqual([
|
|
'boocode-local/sam-desktop/qwen3.6-35b',
|
|
'boocode-local/embedding/gemma-4-12b',
|
|
]);
|
|
});
|
|
|
|
it('leaves already-prefixed ids unchanged', () => {
|
|
const result = prefixBoocodeLocalModels([
|
|
{ id: 'boocode-local/sam-desktop/qwen3.6-35b', label: 'Qwen' },
|
|
]);
|
|
expect(result[0].id).toBe('boocode-local/sam-desktop/qwen3.6-35b');
|
|
});
|
|
|
|
it('preserves label and other fields', () => {
|
|
const result = prefixBoocodeLocalModels([
|
|
{ id: 'sam-desktop/qwen3.6-35b', label: 'Qwen 3.6 35B', isDefault: true },
|
|
]);
|
|
expect(result[0]).toEqual({
|
|
id: 'boocode-local/sam-desktop/qwen3.6-35b',
|
|
label: 'Qwen 3.6 35B',
|
|
isDefault: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- parseModel inner-slash preservation ---
|
|
|
|
describe('gateway model id parsing preserves inner slashes', () => {
|
|
beforeEach(() => {
|
|
loadProvidersFixture([
|
|
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
|
|
]);
|
|
});
|
|
|
|
it('parses "sam-desktop/qwen3.6-35b-a3b-mxfp4" preserving the full wire id', () => {
|
|
const result = resolveGatewayModel('sam-desktop/qwen3.6-35b-a3b-mxfp4');
|
|
expect(result).toHaveProperty('wireModelId', 'qwen3.6-35b-a3b-mxfp4');
|
|
});
|
|
|
|
it('parses model ids with dots and hyphens', () => {
|
|
const result = resolveGatewayModel('sam-desktop/deepseek-r1-0528');
|
|
expect(result).toHaveProperty('wireModelId', 'deepseek-r1-0528');
|
|
});
|
|
});
|
|
|
|
// --- Snapshot advertising shape (integration) ---
|
|
|
|
describe('provider snapshot opencode entry uses boocode-local prefix', () => {
|
|
beforeEach(() => {
|
|
clearProviderSnapshotCache();
|
|
loadProviderConfig('/nonexistent-coder-providers.json');
|
|
vi.restoreAllMocks();
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ id: 'local-model' }, { id: 'qwen3.6-35b' }],
|
|
}),
|
|
}),
|
|
);
|
|
mockProbe.mockResolvedValue({
|
|
ok: true,
|
|
models: [],
|
|
modes: [],
|
|
defaultModeId: null,
|
|
commands: [],
|
|
});
|
|
});
|
|
|
|
it('opencode snapshot entry has boocode-local prefixed model ids', async () => {
|
|
loadProvidersFixture([
|
|
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
|
|
]);
|
|
|
|
const sql = mockSql([
|
|
{
|
|
name: 'opencode',
|
|
install_path: '/usr/bin/opencode',
|
|
supports_acp: true,
|
|
models: null,
|
|
label: 'OpenCode',
|
|
transport: 'acp',
|
|
last_probed_at: null,
|
|
},
|
|
]);
|
|
|
|
const config = {
|
|
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
|
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
|
DEFAULT_MODEL: 'qwen3.6-35b',
|
|
} as import('../config.js').Config;
|
|
|
|
const entries = await getProviderSnapshot(sql, config, '/tmp/test', true);
|
|
const opencode = entries.find((e) => e.name === 'opencode');
|
|
|
|
expect(opencode).toBeDefined();
|
|
// W7: all model ids start with "boocode-local/" and never "llama-swap/".
|
|
for (const m of opencode!.models) {
|
|
expect(m.id).toMatch(/^boocode-local\//);
|
|
expect(m.id).not.toMatch(/^llama-swap\//);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Gateway HTTP proxy tests (W7 audit M3) ---
|
|
|
|
describe('local gateway HTTP proxy', () => {
|
|
let app: import('fastify').FastifyInstance;
|
|
const fetchMock = vi.fn();
|
|
|
|
beforeEach(async () => {
|
|
loadProvidersFixture([
|
|
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://machine-a.test:8401' },
|
|
{ id: 'laptop', label: 'Laptop', baseUrl: 'http://machine-b.test:8401' },
|
|
]);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
fetchMock.mockReset();
|
|
const { default: Fastify } = await import('fastify');
|
|
const { registerLocalGatewayRoutes } = await import('../local-gateway.js');
|
|
app = Fastify({ logger: false });
|
|
registerLocalGatewayRoutes(app);
|
|
await app.ready();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await app.close();
|
|
});
|
|
|
|
it('proxies non-streaming requests to the right provider with the bare wire id', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response(JSON.stringify({ id: 'cmpl-1', model: 'qwen3.6-35b' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toMatchObject({ id: 'cmpl-1' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('http://machine-a.test:8401/v1/chat/completions');
|
|
expect(JSON.parse(init.body as string).model).toBe('qwen3.6-35b');
|
|
});
|
|
|
|
it('routes duplicate wire model names to different machines by provider prefix', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
|
|
});
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'laptop/qwen3.6-35b', messages: [] },
|
|
});
|
|
const urls = fetchMock.mock.calls.map((c) => c[0] as string);
|
|
expect(urls).toEqual([
|
|
'http://machine-a.test:8401/v1/chat/completions',
|
|
'http://machine-b.test:8401/v1/chat/completions',
|
|
]);
|
|
});
|
|
|
|
it('returns 400 for an unknown provider without calling upstream', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'nonexistent/some-model', messages: [] },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toContain('unknown provider');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 400 when the model field is missing', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { messages: [] },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an OpenAI-shaped 502 error when upstream replies non-JSON', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response('<html>gateway error</html>', {
|
|
status: 200,
|
|
headers: { 'content-type': 'text/html' },
|
|
}),
|
|
);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
|
|
});
|
|
expect(res.statusCode).toBe(502);
|
|
expect(res.json().error.message).toContain('non-JSON');
|
|
});
|
|
|
|
it('relays streaming responses chunk-for-chunk with the upstream status', async () => {
|
|
const chunks = ['data: {"a":1}\n\n', 'data: {"a":2}\n\n', 'data: [DONE]\n\n'];
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
|
|
controller.close();
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(
|
|
new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
|
|
);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'laptop/qwen3.6-35b', messages: [], stream: true },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toBe('text/event-stream');
|
|
expect(res.body).toBe(chunks.join(''));
|
|
});
|
|
|
|
it('forwards inbound X-Boo-Source header to upstream', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
|
|
headers: { 'x-boo-source': 'arena' },
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
|
|
expect(callHeaders['X-Boo-Source']).toBe('arena');
|
|
});
|
|
|
|
it('defaults X-Boo-Source to boocoder when not present', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
|
|
expect(callHeaders['X-Boo-Source']).toBe('boocoder');
|
|
});
|
|
});
|
|
|
|
// --- opencode config sync shape (W7 audit B1) ---
|
|
|
|
describe('buildBoocodeLocalProviderConfig', () => {
|
|
it('emits an opencode-routable provider: npm + options.baseURL + models as object map', async () => {
|
|
loadProvidersFixture([
|
|
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://machine-a.test:8401' },
|
|
]);
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ data: [{ id: 'qwen3.6-35b' }] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
try {
|
|
const { buildBoocodeLocalProviderConfig } = await import('../opencode-config-sync.js');
|
|
const cfg = await buildBoocodeLocalProviderConfig('http://127.0.0.1:9502');
|
|
expect(cfg.npm).toBe('@ai-sdk/openai-compatible');
|
|
expect(cfg.options?.baseURL).toBe('http://127.0.0.1:9502/v1');
|
|
expect(Array.isArray(cfg.models)).toBe(false);
|
|
expect(cfg.models).toHaveProperty(['sam-desktop/qwen3.6-35b']);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|