2026-11-06 22:16:38 +01:00
|
|
|
/**
|
|
|
|
|
* @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',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 23:54:45 +01:00
|
|
|
it('a runtime that did not start: what it said, and "Start" to try again (an installed model has no job to resume)', () => {
|
|
|
|
|
expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: { detail: 'spawn ENOENT' } })).toEqual({
|
|
|
|
|
code: 'runtime-start-failed',
|
|
|
|
|
message: { text: 'The runtime did not start on this Mac.' },
|
|
|
|
|
next: 'start',
|
|
|
|
|
detail: 'spawn ENOENT',
|
|
|
|
|
});
|
|
|
|
|
expect(installedBlocker(SAMPLE.installed, { ...LIVE, startFailed: {} })).toEqual({
|
|
|
|
|
code: 'runtime-start-failed',
|
|
|
|
|
message: { text: 'The runtime did not start on this Mac.' },
|
|
|
|
|
next: 'start',
|
|
|
|
|
});
|
|
|
|
|
// A runtime that runs has started: an older failure says nothing any more.
|
|
|
|
|
expect(
|
|
|
|
|
installedBlocker(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', startFailed: { detail: 'x' } })
|
|
|
|
|
).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('the port and a runtime that keeps stopping are said before a failed start', () => {
|
|
|
|
|
const failed = { startFailed: { detail: 'x' } };
|
|
|
|
|
expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, portInUse: true })?.code).toBe('port-in-use');
|
|
|
|
|
expect(installedBlocker(SAMPLE.installed, { ...LIVE, ...failed, gaveUp: true })?.code).toBe('runtime-stopped');
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 22:16:38 +01:00
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 23:54:45 +01:00
|
|
|
it('a failed job never says "Start": a code whose own next step is that says "Try again" there', () => {
|
|
|
|
|
const state: LocalModelStateFile = { ...SAMPLE.failed, error: { code: 'runtime-stopped', detail: 'ECONNREFUSED' } };
|
|
|
|
|
expect(buildInstallView(state, LIVE).error).toEqual({
|
|
|
|
|
code: 'runtime-stopped',
|
|
|
|
|
message: { text: 'The runtime keeps stopping, so Codeman no longer restarts it.' },
|
|
|
|
|
next: 'retry',
|
|
|
|
|
detail: 'ECONNREFUSED',
|
|
|
|
|
});
|
|
|
|
|
// Every next step a failed job is shown with is one its actions can carry out.
|
|
|
|
|
for (const code of Object.keys(LOCAL_MODEL_ERROR_NEXT) as LocalModelErrorCode[]) {
|
|
|
|
|
const next = buildInstallView({ ...SAMPLE.failed, error: { code } }, LIVE).error?.next;
|
|
|
|
|
expect(next, code).not.toBe('start');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-06 22:16:38 +01:00
|
|
|
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}');
|
|
|
|
|
});
|
|
|
|
|
});
|