Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
import { findOpencodeServer, parseModelRef, opencodeBaseUrls, opencodePrompt } from '../opencode';
|
|
|
|
/**
|
|
* The single most important behaviour under test is the SPA-catch-all trap:
|
|
* `opencode serve` answers 200 with the web UI's index.html for ANY unknown
|
|
* path, so a probe that trusts `res.ok` "verifies" endpoints that do not
|
|
* exist. That is not hypothetical — it is exactly how this integration was
|
|
* first built wrong (against an assumed OpenAI-compatible `/v1/models` that
|
|
* only ever returned HTML).
|
|
*/
|
|
|
|
const HTML_CATCHALL = {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
|
|
json: async () => {
|
|
throw new Error('not json');
|
|
},
|
|
};
|
|
|
|
function jsonResponse(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => body,
|
|
};
|
|
}
|
|
|
|
describe('parseModelRef', () => {
|
|
it('splits providerID/modelID, keeping slashes inside the model id', () => {
|
|
expect(parseModelRef('opencode/deepseek-v4-flash-free')).toEqual({
|
|
providerID: 'opencode',
|
|
modelID: 'deepseek-v4-flash-free',
|
|
});
|
|
// Real provider ids do contain slashes (e.g. openrouter's
|
|
// "anthropic/claude-..."), so only the FIRST slash separates.
|
|
expect(parseModelRef('openrouter/anthropic/claude-sonnet-4.5')).toEqual({
|
|
providerID: 'openrouter',
|
|
modelID: 'anthropic/claude-sonnet-4.5',
|
|
});
|
|
});
|
|
|
|
it('rejects malformed refs rather than guessing', () => {
|
|
expect(parseModelRef('noslash')).toBeNull();
|
|
expect(parseModelRef('/leading')).toBeNull();
|
|
expect(parseModelRef('trailing/')).toBeNull();
|
|
expect(parseModelRef('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodeBaseUrls', () => {
|
|
const original = process.env.OPENCODE_BASE_URL;
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.OPENCODE_BASE_URL;
|
|
else process.env.OPENCODE_BASE_URL = original;
|
|
});
|
|
|
|
it('refuses a non-loopback override — this class must never reach off-machine', () => {
|
|
process.env.OPENCODE_BASE_URL = 'https://evil.example.com';
|
|
const urls = opencodeBaseUrls();
|
|
expect(urls.some((u) => u.includes('evil.example.com'))).toBe(false);
|
|
expect(urls[0]).toMatch(/127\.0\.0\.1|localhost/);
|
|
});
|
|
|
|
it('honours a loopback override, trying it first', () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://127.0.0.1:9999/';
|
|
expect(opencodeBaseUrls()[0]).toBe('http://127.0.0.1:9999');
|
|
});
|
|
});
|
|
|
|
describe('findOpencodeServer', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('does NOT accept the web UI catch-all as a working API (200 + HTML)', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
|
|
it('parses the real /api/model shape into providerID/modelID refs', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
data: [
|
|
{ id: 'deepseek-v4-flash-free', providerID: 'opencode', name: 'DeepSeek V4 Flash Free' },
|
|
{ id: 'deepseek-chat', providerID: 'deepseek' },
|
|
{ id: '', providerID: 'broken' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const found = await findOpencodeServer();
|
|
expect(found?.models.map((m) => m.ref)).toEqual([
|
|
'opencode/deepseek-v4-flash-free',
|
|
'deepseek/deepseek-chat',
|
|
]);
|
|
expect(found?.models[0].label).toBe('DeepSeek V4 Flash Free (opencode)');
|
|
});
|
|
|
|
it('returns null when the server answers JSON with no models', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodePrompt', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns only the text parts — never the model\'s private reasoning', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
parts: [
|
|
{ type: 'step-start' },
|
|
{ type: 'reasoning', text: 'SECRET chain of thought that must not be shown' },
|
|
{ type: 'text', text: 'The visible answer.' },
|
|
{ type: 'step-finish' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'opencode', modelID: 'm' }, 'sys', 'q');
|
|
expect(result).toEqual({ ok: true, answer: 'The visible answer.' });
|
|
if (result.ok) expect(result.answer).not.toContain('SECRET');
|
|
});
|
|
|
|
it('fails cleanly when no session can be created', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('fails cleanly when the reply carries no text part', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(jsonResponse({ parts: [{ type: 'step-start' }, { type: 'reasoning', text: 'only thinking' }] })) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' });
|
|
});
|
|
});
|