mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(local-model): a library of models, with one in use
The local model window could hold one model. A second install was refused (409 already-installed), and a model a Remove kept could never be freed from the window again. The store can now hold several models, and exactly one of them is the model in use: the one the endpoint record lists, the runtime is started for and a session may be launched on. Server - LocalModelStateFile.library lists every model that passed the check (id, label, context, digest, size, coding agent). The service writes the entry when verify passes; the reducer carries it and remove drops it. An installed file of an earlier build counts its one model. - POST /api/local-model/install is allowed from installed: it adds a model, and the earlier one stays. A replaced job no longer deletes a model of the library, only an unchecked or partial download. - POST /api/local-model/use switches to a checked model: a job of the two steps verify and connect. The stored digest is compared before anything is loaded (model-changed otherwise). Nothing is downloaded. - DELETE /api/local-model/models deletes one model by its name, through the runtime's own delete, never by a path, never the model in use. - Adding and switching answer 409 sessions-open while a live session uses the endpoint. Until such a job ends installed, a session start is refused with the new code job-unfinished. - The view carries library (the state's entries joined with what the running runtime lists: ready, changed, missing, unverified) and store (the size of the blob folder). Both are cached and refreshed in the background, never read on the one-second poll. - With nothing installed, remove is offered while the store still holds models an earlier Remove kept. Window - The installed screen lists the models on this Mac with the context each was checked with, its state and its size on disk, with Use and Delete per row (Delete asks once, in the row) and Add a model. - In the chooser a model that is on this Mac already says so and is switched to without a download or a confirm screen. - A paused or failed job that has a checked model beside it shows the way back to that model. Every route is admin only in multi-user mode, like the existing ones. Not run on a Mac: the tests use fakes and the fake runtime, and the listing, store size and delete were checked against a real Ollama 0.13.0 on Linux, not the pinned 0.40.15 build for macOS. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
f62c1f7e2f
commit
70dc42b248
20 files changed
+4088
-201
No files matched your search
Vendored
+95
-37
@@ -18,6 +18,7 @@
|
||||
* | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) |
|
||||
* | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` |
|
||||
* | `DELETE /api/delete` | 200, or 404 for a model that is not installed |
|
||||
* | | (the model, or one of `otherModels`) |
|
||||
* | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) |
|
||||
* | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` |
|
||||
* | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` |
|
||||
@@ -220,6 +221,14 @@ export interface FakeOllamaOptions {
|
||||
spawn?: Partial<FakeSpawnSwitches>;
|
||||
/** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */
|
||||
archiveFiles?: Record<string, string>;
|
||||
/**
|
||||
* More models the registry has beside `model`: each can be pulled (a short stream that
|
||||
* succeeds), listed, loaded, checked and deleted on its own. The pull, chat and tool
|
||||
* switches apply to them too, except the pull's failures and shapes, which are `model`'s.
|
||||
*/
|
||||
otherModels?: FakeOllamaModel[];
|
||||
/** The ids of `otherModels` that are on disk from the start. */
|
||||
othersInstalled?: string[];
|
||||
}
|
||||
|
||||
export interface CapturedRequest {
|
||||
@@ -304,6 +313,10 @@ export interface FakeOllama {
|
||||
archiveRequests: number;
|
||||
/** Every child `spawn` made, oldest first. */
|
||||
children: FakeRuntimeChild[];
|
||||
/** The ids of `otherModels` that are on disk: pulled, or installed from the start. */
|
||||
others: string[];
|
||||
/** The id of the model the last chat loaded. The runtime holds one model at a time. */
|
||||
loadedId?: string;
|
||||
};
|
||||
/** The fake runtime archive: a real gzipped tar. */
|
||||
readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string };
|
||||
@@ -436,7 +449,26 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
pulls: 0,
|
||||
archiveRequests: 0,
|
||||
children: [],
|
||||
others: [...(options.othersInstalled ?? [])],
|
||||
};
|
||||
const otherModels = options.otherModels ?? [];
|
||||
/** One of `otherModels` by its id, whether it is on disk or not. */
|
||||
const otherModel = (id: unknown): FakeOllamaModel | undefined => otherModels.find((other) => other.id === id);
|
||||
/** The model `id` names when it is on disk: `model` or one of the others. */
|
||||
const onDisk = (id: unknown): FakeOllamaModel | undefined => {
|
||||
if (id === model.id) return state.installed ? model : undefined;
|
||||
const other = otherModel(id);
|
||||
return other && state.others.includes(other.id) ? other : undefined;
|
||||
};
|
||||
const tagRow = (entry: FakeOllamaModel): Record<string, unknown> => ({
|
||||
name: entry.id,
|
||||
model: entry.id,
|
||||
modified_at: '2026-11-06T15:10:02.465346347+01:00',
|
||||
size: entry.bytes,
|
||||
digest: entry.digest,
|
||||
details: { ...modelDetails(), format: entry.format, context_length: 262144, runner: entry.runner },
|
||||
capabilities: entry.capabilities,
|
||||
});
|
||||
const requests: CapturedRequest[] = [];
|
||||
const sockets = new Set<Socket>();
|
||||
let server: Server | undefined;
|
||||
@@ -475,6 +507,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
};
|
||||
|
||||
if (!(await line({ status: 'pulling manifest' }))) return;
|
||||
// One of the other models: a short stream that succeeds.
|
||||
const other = otherModel(askedId);
|
||||
if (other && pull.fail !== 'not-found') {
|
||||
const layer = { status: 'pulling model', digest: 'sha256:other', total: other.bytes };
|
||||
if (!(await line({ ...layer, completed: 0 }))) return;
|
||||
if (!(await line({ ...layer, completed: other.bytes }))) return;
|
||||
if (!(await line({ status: 'writing manifest' }))) return;
|
||||
if (!state.others.includes(other.id)) state.others.push(other.id);
|
||||
await line({ status: 'success' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// Not captured on a Mac: the wording of the runtime for a tag the registry does not have.
|
||||
if (pull.fail === 'not-found' || askedId !== model.id) {
|
||||
await line({ error: 'pull model manifest: file does not exist' });
|
||||
@@ -528,7 +572,8 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
|
||||
function handleChat(res: ServerResponse, body: unknown): void {
|
||||
const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {};
|
||||
if (!state.installed || request.model !== model.id) {
|
||||
const served = onDisk(request.model);
|
||||
if (!served) {
|
||||
sendJson(res, 404, {
|
||||
error: {
|
||||
message: `model '${String(request.model)}' not found`,
|
||||
@@ -544,7 +589,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
id: `chatcmpl-${chatCounter++}`,
|
||||
object: 'chat.completion',
|
||||
created: 1793968038,
|
||||
model: model.id,
|
||||
model: served.id,
|
||||
system_fingerprint: 'fp_ollama',
|
||||
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
|
||||
usage: {
|
||||
@@ -563,12 +608,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
if (switches.ps.loaded !== false) {
|
||||
state.loaded = true;
|
||||
state.loadedId = served.id;
|
||||
}
|
||||
sendJson(res, 200, envelope({ content: 'pong' }, 'stop'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
if (switches.ps.loaded !== false) {
|
||||
state.loaded = true;
|
||||
state.loadedId = served.id;
|
||||
}
|
||||
const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {};
|
||||
const name = typeof fn.name === 'string' ? fn.name : 'tool';
|
||||
const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>;
|
||||
@@ -648,39 +699,31 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
}
|
||||
if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version });
|
||||
if (path === '/api/tags' && method === 'GET') {
|
||||
const models = state.installed
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
modified_at: '2026-11-06T15:10:02.465346347+01:00',
|
||||
size: model.bytes,
|
||||
digest: model.digest,
|
||||
details: { ...modelDetails(), context_length: 262144, runner: model.runner },
|
||||
capabilities: model.capabilities,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const models = [
|
||||
...(state.installed ? [tagRow(model)] : []),
|
||||
...otherModels.filter((other) => state.others.includes(other.id)).map(tagRow),
|
||||
];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/ps' && method === 'GET') {
|
||||
const size = switches.ps.size ?? model.bytes + 100_000_000;
|
||||
const models =
|
||||
state.installed && state.loaded && switches.ps.loaded !== false
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
size,
|
||||
digest: model.digest,
|
||||
details: modelDetails(),
|
||||
expires_at: '2026-11-06T15:20:09.488045+01:00',
|
||||
size_vram: switches.ps.sizeVram ?? size,
|
||||
context_length: switches.ps.contextLength ?? contextLength,
|
||||
runner: model.runner,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
// The runtime holds ONE model: the one the last chat loaded.
|
||||
const held = state.loaded && switches.ps.loaded !== false ? onDisk(state.loadedId ?? model.id) : undefined;
|
||||
const size = switches.ps.size ?? (held ?? model).bytes + 100_000_000;
|
||||
const models = held
|
||||
? [
|
||||
{
|
||||
name: held.id,
|
||||
model: held.id,
|
||||
size,
|
||||
digest: held.digest,
|
||||
details: modelDetails(),
|
||||
expires_at: '2026-11-06T15:20:09.488045+01:00',
|
||||
size_vram: switches.ps.sizeVram ?? size,
|
||||
context_length: switches.ps.contextLength ?? contextLength,
|
||||
runner: held.runner,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/pull' && method === 'POST') return handlePull(res, body);
|
||||
@@ -688,20 +731,35 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
// Not captured: the answers of a delete (200 with no body, 404 with an error text).
|
||||
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
|
||||
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
|
||||
const other = otherModel(askedId);
|
||||
if (other && state.others.includes(other.id)) {
|
||||
state.others = state.others.filter((id) => id !== other.id);
|
||||
if (state.loadedId === other.id) {
|
||||
state.loaded = false;
|
||||
state.loadedId = undefined;
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!state.installed || askedId !== model.id) {
|
||||
return sendJson(res, 404, { error: `model '${String(askedId)}' not found` });
|
||||
}
|
||||
state.installed = false;
|
||||
state.loaded = false;
|
||||
state.loadedId = undefined;
|
||||
state.completedBytes = 0;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (path === '/v1/models' && method === 'GET') {
|
||||
const data = state.installed
|
||||
? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }]
|
||||
: null;
|
||||
const ids = [
|
||||
...(state.installed ? [model.id] : []),
|
||||
...otherModels.filter((other) => state.others.includes(other.id)).map((other) => other.id),
|
||||
];
|
||||
const data =
|
||||
ids.length > 0 ? ids.map((id) => ({ id, object: 'model', created: 1793967749, owned_by: 'library' })) : null;
|
||||
return sendJson(res, 200, { object: 'list', data });
|
||||
}
|
||||
if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body);
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('the install from the first GET to Remove, real modules over a fake run
|
||||
endpointId: 'local-ollama',
|
||||
folder: world.paths.root,
|
||||
loaded: { context: pick.context, onGpu: true },
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
|
||||
});
|
||||
|
||||
@@ -682,6 +682,154 @@ describe('the install from the first GET to Remove, real modules over a fake run
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('the library: a second model, the switch back, and the delete of one model', () => {
|
||||
it('installs A, adds B beside it, switches back to A, and deletes B, with one model in use throughout', async () => {
|
||||
const { app, get, settled, install: clickInstall } = await routes();
|
||||
const hostsModels = async () =>
|
||||
(await readCustomModelHosts(getDataDir())).find((host) => host.id === 'local-ollama')?.models;
|
||||
const post = (url: string, payload: unknown) => app.inject({ method: 'POST', url, payload: payload as object });
|
||||
const deleteModel = (modelId: string) =>
|
||||
app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId } });
|
||||
|
||||
// ---- A: the first install ----
|
||||
world.agentInstalled = true;
|
||||
const first = await get();
|
||||
const a = first.recommendation.pick!;
|
||||
const bRow = first.recommendation.alternatives[0];
|
||||
expect(bRow).toBeDefined();
|
||||
expect(bRow.modelId).not.toBe(a.modelId);
|
||||
const B_DIGEST = 'b'.repeat(64);
|
||||
world.runtime = createFakeOllama({
|
||||
model: { id: a.modelId, bytes: a.downloadBytes },
|
||||
contextLength: a.context,
|
||||
otherModels: [
|
||||
{
|
||||
id: bRow.modelId,
|
||||
bytes: bRow.downloadBytes,
|
||||
digest: B_DIGEST,
|
||||
format: 'gguf',
|
||||
runner: 'ggml',
|
||||
quantization: 'Q4_K_M',
|
||||
capabilities: ['completion', 'tools'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = world.runtime;
|
||||
expect((await clickInstall(a)).statusCode).toBe(200);
|
||||
expect((await settled()).state).toBe('installed');
|
||||
|
||||
let status = await get();
|
||||
expect(status.install.library).toEqual([
|
||||
{
|
||||
id: a.modelId,
|
||||
label: a.modelLabel,
|
||||
context: a.context,
|
||||
sizeBytes: a.downloadBytes,
|
||||
sizeGb: formatGb(a.downloadBytes),
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
// The store of the fake runtime writes no blobs: measured, and empty.
|
||||
expect(status.install.store).toEqual({ bytes: 0, gb: '0.0' });
|
||||
// The chooser says the row is the model in use.
|
||||
expect(status.recommendation.pick?.library).toBe('active');
|
||||
expect(status.install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
||||
|
||||
// ---- B: a second model. Refused while a session uses A. ----
|
||||
const b = [status.recommendation.pick!, ...status.recommendation.alternatives].find(
|
||||
(row) => row.modelId === bRow.modelId
|
||||
)!;
|
||||
// The runtime is on disk now, so the server's total for B is the model alone.
|
||||
expect(b.installBytes).toBe(b.downloadBytes);
|
||||
const addB = { modelId: b.modelId, cli: 'opencode', context: b.context, acknowledgedBytes: b.installBytes };
|
||||
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
||||
const busy = await post('/api/local-model/install', addB);
|
||||
expect(busy.statusCode).toBe(409);
|
||||
expect(busy.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
|
||||
expect(runtime.state.pulls).toBe(1);
|
||||
world.sessions.clear();
|
||||
|
||||
// The fake runtime reports the context it was "started with": B's from here on.
|
||||
runtime.switches.ps.contextLength = b.context;
|
||||
const started = await post('/api/local-model/install', addB);
|
||||
expect(started.statusCode).toBe(200);
|
||||
expect(started.json().data).toMatchObject({ state: 'installing', model: { id: b.modelId } });
|
||||
const withB = await settled();
|
||||
expect(withB.error).toBeUndefined();
|
||||
expect(withB).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: b.modelId, label: b.modelLabel },
|
||||
context: b.context,
|
||||
endpointId: 'local-ollama',
|
||||
});
|
||||
// Both are on disk, A was not deleted, and the ONE endpoint record lists the model in use.
|
||||
expect(runtime.state.installed).toBe(true);
|
||||
expect(runtime.state.others).toEqual([b.modelId]);
|
||||
expect(await hostsModels()).toEqual([b.modelId]);
|
||||
expect(runtime.requests.some((request) => request.path === '/api/delete')).toBe(false);
|
||||
await vi.waitFor(async () => {
|
||||
const library = (await get()).install.library;
|
||||
expect(library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
|
||||
[b.modelId, true, 'ready', []],
|
||||
[a.modelId, false, 'ready', ['use', 'delete']],
|
||||
]);
|
||||
});
|
||||
const stored = await readLocalModelState();
|
||||
expect(stored.library?.map((entry) => [entry.id, entry.context, entry.digest])).toEqual([
|
||||
[a.modelId, a.context, runtime.model.digest],
|
||||
[b.modelId, b.context, B_DIGEST],
|
||||
]);
|
||||
// Only the model in use may be launched on the endpoint.
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', b.modelId)).toBeUndefined();
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toContain('is in use on this Mac');
|
||||
|
||||
// ---- The model in use cannot be deleted ----
|
||||
const active = await deleteModel(b.modelId);
|
||||
expect(active.statusCode).toBe(409);
|
||||
expect(active.json().data).toMatchObject({ reason: 'model-active' });
|
||||
expect(runtime.state.others).toEqual([b.modelId]);
|
||||
|
||||
// ---- Back to A: nothing is downloaded ----
|
||||
status = await get();
|
||||
expect(status.recommendation.pick?.library ?? status.recommendation.alternatives[0].library).toBeDefined();
|
||||
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
||||
const useBusy = await post('/api/local-model/use', { modelId: a.modelId });
|
||||
expect(useBusy.statusCode).toBe(409);
|
||||
expect(useBusy.json().data).toMatchObject({ reason: 'sessions-open' });
|
||||
world.sessions.clear();
|
||||
|
||||
runtime.switches.ps.contextLength = a.context;
|
||||
const pullsBefore = runtime.state.pulls;
|
||||
const switched = await post('/api/local-model/use', { modelId: a.modelId });
|
||||
expect(switched.statusCode).toBe(200);
|
||||
expect(switched.json().data).toMatchObject({ state: 'installing', steps: ['verify', 'connect'] });
|
||||
const backOnA = await settled();
|
||||
expect(backOnA.error).toBeUndefined();
|
||||
expect(backOnA).toMatchObject({ state: 'installed', model: { id: a.modelId }, context: a.context });
|
||||
expect(runtime.state.pulls).toBe(pullsBefore);
|
||||
expect(await hostsModels()).toEqual([a.modelId]);
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toBeUndefined();
|
||||
|
||||
// ---- Delete B: through the runtime, by its name ----
|
||||
const notOurs = await deleteModel('someone-else:7b');
|
||||
expect(notOurs.statusCode).toBe(400);
|
||||
const gone = await deleteModel(b.modelId);
|
||||
expect(gone.statusCode).toBe(200);
|
||||
expect(gone.json().data.library.map((row: { id: string }) => row.id)).toEqual([a.modelId]);
|
||||
expect(runtime.state.others).toEqual([]);
|
||||
expect(runtime.state.installed).toBe(true);
|
||||
const deletes = runtime.requests.filter((request) => request.path === '/api/delete');
|
||||
expect(deletes.map((request) => request.body)).toEqual([{ model: b.modelId }]);
|
||||
expect((await readLocalModelState()).library?.map((entry) => entry.id)).toEqual([a.modelId]);
|
||||
expect((await get()).install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
||||
// A is in use: it can only go with the whole installation.
|
||||
expect((await deleteModel(a.modelId)).statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
|
||||
/**
|
||||
* Installs up to the model download, then "kills" the server: its job stops and it signals
|
||||
|
||||
@@ -17,13 +17,18 @@ import {
|
||||
allowedActions,
|
||||
buildInstallError,
|
||||
buildInstallView,
|
||||
buildLibraryRows,
|
||||
formatInstallMessage,
|
||||
INSTALL_STATE_TABLE,
|
||||
INSTALL_STEP_TABLE,
|
||||
INSTALL_TRANSITIONS,
|
||||
installedBlocker,
|
||||
jobSteps,
|
||||
libraryOf,
|
||||
markLibraryCandidates,
|
||||
reduceInstallJob,
|
||||
upsertLibraryEntry,
|
||||
USE_JOB_STEPS,
|
||||
type InstallJobEvent,
|
||||
type InstallJobEventType,
|
||||
type InstallLiveFacts,
|
||||
@@ -38,12 +43,15 @@ import {
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelLibraryEntry,
|
||||
type LocalModelStateFile,
|
||||
type Recommendation,
|
||||
} from '../src/types.js';
|
||||
|
||||
const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
|
||||
const EVENT_TYPES: InstallJobEventType[] = [
|
||||
'install',
|
||||
'use',
|
||||
'progress',
|
||||
'step-done',
|
||||
'fail',
|
||||
@@ -60,6 +68,48 @@ const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, mea
|
||||
const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false };
|
||||
const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 };
|
||||
|
||||
/** The model in use as the library lists it, and a second checked model beside it. */
|
||||
const ENTRY: LocalModelLibraryEntry = {
|
||||
id: MODEL.id,
|
||||
label: MODEL.label,
|
||||
context: MODEL.context,
|
||||
digest: 'abc123',
|
||||
bytes: 23_105_798_956,
|
||||
cli: CLI,
|
||||
};
|
||||
const OTHER: LocalModelLibraryEntry = {
|
||||
id: 'qwen3.8:27b-mtp-q4_k_m',
|
||||
label: 'Qwen3.8 27B',
|
||||
context: 65536,
|
||||
digest: 'def456',
|
||||
bytes: 17_741_872_154,
|
||||
cli: CLI,
|
||||
};
|
||||
|
||||
/** The two as rows of the view, while nothing is known from the runtime. */
|
||||
const ENTRY_ROW = {
|
||||
id: ENTRY.id,
|
||||
label: ENTRY.label,
|
||||
context: ENTRY.context,
|
||||
sizeBytes: 23_105_798_956,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
};
|
||||
const OTHER_ROW = {
|
||||
id: OTHER.id,
|
||||
label: OTHER.label,
|
||||
context: OTHER.context,
|
||||
sizeBytes: 17_741_872_154,
|
||||
sizeGb: '17.7',
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use'],
|
||||
};
|
||||
|
||||
const JOB: LocalModelStateFile = {
|
||||
schema: 1,
|
||||
state: 'installing',
|
||||
@@ -69,6 +119,8 @@ const JOB: LocalModelStateFile = {
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
|
||||
// An earlier install left one checked model: what `use` can go back to.
|
||||
library: [OTHER],
|
||||
};
|
||||
|
||||
/** One state file of each state, as the job leaves it. */
|
||||
@@ -84,6 +136,7 @@ const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = {
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
loaded: LOADED,
|
||||
library: [OTHER],
|
||||
connection: CONNECTION,
|
||||
},
|
||||
};
|
||||
@@ -96,6 +149,11 @@ const EVENT: Record<InstallJobEventType, InstallJobEvent> = {
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
steps: ['runtime', 'model', 'verify', 'connect'],
|
||||
},
|
||||
use: {
|
||||
type: 'use',
|
||||
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
|
||||
cli: CLI,
|
||||
},
|
||||
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' },
|
||||
@@ -151,12 +209,17 @@ describe('the tables', () => {
|
||||
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' },
|
||||
paused: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'paused' },
|
||||
failed: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'failed' },
|
||||
installed: { install: 'installing', use: 'installing', remove: 'idle', restart: 'installed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a switch is the two steps that load and connect a model, and downloads nothing', () => {
|
||||
expect(USE_JOB_STEPS).toEqual(['verify', 'connect']);
|
||||
for (const step of USE_JOB_STEPS) expect(INSTALL_STEP_TABLE[step].progress).toBe(false);
|
||||
});
|
||||
|
||||
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];
|
||||
@@ -221,10 +284,10 @@ describe('reduceInstallJob: every state with every event, from the transition ta
|
||||
}
|
||||
}
|
||||
|
||||
it('covers all 40 pairs, 18 of them listed', () => {
|
||||
it('covers all 45 pairs, 22 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);
|
||||
expect(STATES.length * EVENT_TYPES.length).toBe(45);
|
||||
expect(listed).toHaveLength(22);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,11 +309,15 @@ describe('the state table agrees with the reducer and the view', () => {
|
||||
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('use')).toBe(accepts('use'));
|
||||
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'));
|
||||
// Remove is refused only while a job runs. From idle it only clears what an earlier
|
||||
// Remove kept, and the view lists it there only while the store holds something.
|
||||
expect(row.actions.includes('remove')).toBe(accepts('remove'));
|
||||
// A model is deleted through the runtime, which a state without a job in flight and with
|
||||
// a runtime set up can have: never idle (no runtime), never while a job runs.
|
||||
expect(row.actions.includes('delete-model')).toBe(state !== 'idle' && state !== 'installing');
|
||||
// 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');
|
||||
@@ -292,6 +359,7 @@ describe('reduceInstallJob: what each event writes', () => {
|
||||
replacedModelId: 'qwen3.6:35b-a3b',
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
runtime: RUNTIME,
|
||||
library: [OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
// Gone: the error, the progress, what the runtime had loaded.
|
||||
@@ -421,32 +489,65 @@ describe('reduceInstallJob: what each event writes', () => {
|
||||
});
|
||||
|
||||
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']);
|
||||
it('outside installed the runtime, the port and the digest narrow nothing', () => {
|
||||
const busy = { ...LIVE, running: true, gaveUp: true, portInUse: true };
|
||||
expect(allowedActions(SAMPLE.installing, busy)).toEqual(['cancel']);
|
||||
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']);
|
||||
for (const state of ['paused', 'failed'] as const) {
|
||||
// `use` because a checked model is there; no delete while nothing is known from the runtime.
|
||||
expect(allowedActions(SAMPLE[state], busy)).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
expect(allowedActions(SAMPLE[state], LIVE)).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
}
|
||||
});
|
||||
|
||||
it('installed and running: a session can be started and the runtime stopped', () => {
|
||||
it('idle: Remove is offered only while the model store still holds something', () => {
|
||||
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
|
||||
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 0 })).toEqual(['install']);
|
||||
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 })).toEqual(['install', 'remove']);
|
||||
});
|
||||
|
||||
it('use and delete-model are listed only while a row of the library offers them', () => {
|
||||
// A first job: no checked model yet, so nothing to switch to and nothing to delete.
|
||||
const first: LocalModelStateFile = { ...SAMPLE.paused, library: undefined };
|
||||
expect(allowedActions(first, LIVE)).toEqual(['resume', 'install', 'remove']);
|
||||
// The runtime lists the other model: it can be deleted as well as switched to.
|
||||
const tags = [{ name: OTHER.id, size: 17_741_872_154, digest: OTHER.digest }];
|
||||
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags })).toEqual([
|
||||
'resume',
|
||||
'install',
|
||||
'use',
|
||||
'delete-model',
|
||||
'remove',
|
||||
]);
|
||||
// The rows it was given decide, when it is given them.
|
||||
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags }, [])).toEqual([
|
||||
'resume',
|
||||
'install',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installed and running: a session can be started, the runtime stopped, a model added', () => {
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([
|
||||
'start-session',
|
||||
'stop',
|
||||
'install',
|
||||
'use',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installed and stopped: it can be started, and no session is offered', () => {
|
||||
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']);
|
||||
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'install', 'use', 'remove']);
|
||||
});
|
||||
|
||||
it('installed and running a model that changed: no session', () => {
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']);
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual([
|
||||
'stop',
|
||||
'install',
|
||||
'use',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => {
|
||||
@@ -456,8 +557,8 @@ describe('allowedActions', () => {
|
||||
|
||||
it('never returns the table row itself', () => {
|
||||
const actions = allowedActions(SAMPLE.idle, LIVE);
|
||||
actions.push('remove');
|
||||
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']);
|
||||
actions.push('cancel');
|
||||
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install', 'remove']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,10 +636,20 @@ describe('buildInstallView', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
});
|
||||
});
|
||||
|
||||
it('idle with a store an earlier Remove kept: its size, and Remove to free it', () => {
|
||||
const view = buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 });
|
||||
expect(view.store).toEqual({ bytes: 23_105_798_956, gb: '23.1' });
|
||||
expect(view.library).toEqual([]);
|
||||
expect(view.actions).toEqual(['install', 'remove']);
|
||||
// A store that was measured as empty is said as that, and offers nothing.
|
||||
expect(buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 0 }).store).toEqual({ bytes: 0, gb: '0.0' });
|
||||
});
|
||||
|
||||
it('once the runtime is unpacked an install downloads nothing for it', () => {
|
||||
const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true });
|
||||
expect(view.runtime).toEqual({
|
||||
@@ -564,6 +675,8 @@ describe('buildInstallView', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
// The checked model of an earlier install: listed, with nothing to do while a job runs.
|
||||
library: [{ ...OTHER_ROW, actions: [] }],
|
||||
actions: ['cancel'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
@@ -593,7 +706,9 @@ describe('buildInstallView', () => {
|
||||
completedGb: '6.2',
|
||||
totalGb: '23.1',
|
||||
});
|
||||
expect(view.actions).toEqual(['resume', 'install', 'remove']);
|
||||
expect(view.actions).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
// The way back from a job that stopped: the model that was checked before.
|
||||
expect(view.library).toEqual([OTHER_ROW]);
|
||||
});
|
||||
|
||||
it('a speed of zero has no time left, and a download without a total has no progress at all', () => {
|
||||
@@ -677,7 +792,8 @@ describe('buildInstallView', () => {
|
||||
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'],
|
||||
library: [ENTRY_ROW, OTHER_ROW],
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
cli: 'opencode',
|
||||
@@ -692,7 +808,9 @@ describe('buildInstallView', () => {
|
||||
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']);
|
||||
expect(view.actions).toEqual(['stop', 'install', 'use', 'remove']);
|
||||
// The row of the model in use says the same thing the error does.
|
||||
expect(view.library[0]).toMatchObject({ id: MODEL.id, active: true, status: 'changed' });
|
||||
});
|
||||
|
||||
it('a model that was not all on the GPU says so', () => {
|
||||
@@ -713,6 +831,317 @@ describe('buildInstallView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduceInstallJob: the library', () => {
|
||||
it('libraryOf: the list of the file, and for an installed file without one its model', () => {
|
||||
expect(libraryOf(SAMPLE.idle)).toEqual([]);
|
||||
expect(libraryOf(SAMPLE.paused)).toEqual([OTHER]);
|
||||
// A file an earlier build wrote: the model in use is the library.
|
||||
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
|
||||
expect(libraryOf(old)).toEqual([ENTRY]);
|
||||
// The model in use is always one of them, first, also when the list does not name it.
|
||||
expect(libraryOf(SAMPLE.installed)).toEqual([ENTRY, OTHER]);
|
||||
// Never twice, and the list's own entry wins.
|
||||
const listed: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, { ...ENTRY, context: 65536 }] };
|
||||
expect(libraryOf(listed)).toEqual([OTHER, { ...ENTRY, context: 65536 }]);
|
||||
// A model without a stored digest could never be compared: it is not one of them.
|
||||
const noDigest: LocalModelStateFile = { ...SAMPLE.installed, library: undefined, model: MODEL };
|
||||
expect(libraryOf(noDigest)).toEqual([]);
|
||||
// A job's model is not in the library before it was checked.
|
||||
expect(libraryOf({ ...SAMPLE.installing, library: undefined })).toEqual([]);
|
||||
});
|
||||
|
||||
it('libraryOf hands out a list of its own', () => {
|
||||
const list = libraryOf(SAMPLE.paused);
|
||||
list.pop();
|
||||
expect(SAMPLE.paused.library).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it('upsertLibraryEntry replaces the entry with the same id in place, else adds at the end', () => {
|
||||
const next = { ...OTHER, digest: 'fff', context: 131072 };
|
||||
expect(upsertLibraryEntry([ENTRY, OTHER], next)).toEqual([ENTRY, next]);
|
||||
expect(upsertLibraryEntry([ENTRY], OTHER)).toEqual([ENTRY, OTHER]);
|
||||
expect(upsertLibraryEntry([], OTHER)).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it('an install from installed adds a model: the model in use stays in the library and is NOT deleted', () => {
|
||||
const next = reduceInstallJob(SAMPLE.installed, 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 },
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
runtime: RUNTIME,
|
||||
// The model that was in use is written into the list: its state member is the new job's now.
|
||||
library: [ENTRY, OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
expect(next).not.toHaveProperty('replacedModelId');
|
||||
expect(next).not.toHaveProperty('loaded');
|
||||
});
|
||||
|
||||
it('an install from an installed file of an earlier build keeps its one model too', () => {
|
||||
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
|
||||
const next = reduceInstallJob(old, EVENT.install);
|
||||
expect(next.library).toEqual([ENTRY]);
|
||||
expect(next).not.toHaveProperty('replacedModelId');
|
||||
});
|
||||
|
||||
it('a replaced job deletes its model only when that model was never checked', () => {
|
||||
// The stopped job's model is a checked one (it failed after its check): it stays.
|
||||
const checked: LocalModelStateFile = { ...SAMPLE.failed, library: [OTHER, ENTRY] };
|
||||
expect(reduceInstallJob(checked, EVENT.install)).not.toHaveProperty('replacedModelId');
|
||||
// Not checked: what it left is deleted, as before.
|
||||
expect(reduceInstallJob(SAMPLE.failed, EVENT.install).replacedModelId).toBe(MODEL.id);
|
||||
// A leftover that is waiting to be deleted is dropped from the wait once it is a checked model.
|
||||
const waiting: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: OTHER.id };
|
||||
expect(reduceInstallJob(waiting, { ...EVENT.install, model: MODEL } as InstallJobEvent)).not.toHaveProperty(
|
||||
'replacedModelId'
|
||||
);
|
||||
});
|
||||
|
||||
it('use from installed: the two steps for the checked model, with its stored digest', () => {
|
||||
expect(reduceInstallJob(SAMPLE.installed, EVENT.use)).toEqual({
|
||||
schema: 1,
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
library: [ENTRY, OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
});
|
||||
|
||||
it('use from a stopped job replaces it, and deletes what that job left of an unchecked model', () => {
|
||||
for (const state of ['paused', 'failed'] as const) {
|
||||
const next = reduceInstallJob(SAMPLE[state], EVENT.use);
|
||||
expect(next.state).toBe('installing');
|
||||
expect(next.step).toBe('verify');
|
||||
expect(next.model?.id).toBe(OTHER.id);
|
||||
expect(next.replacedModelId).toBe(MODEL.id);
|
||||
expect(next).not.toHaveProperty('error');
|
||||
expect(next).not.toHaveProperty('progress');
|
||||
expect(next.library).toEqual([OTHER]);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a use it cannot apply: a model that is not in the library, a state without a runtime', () => {
|
||||
const unknown = { ...EVENT.use, model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536, digest: 'x' } };
|
||||
expect(reduceInstallJob(SAMPLE.installed, unknown as InstallJobEvent)).toBe(SAMPLE.installed);
|
||||
const noRuntime: LocalModelStateFile = { ...SAMPLE.paused, runtime: undefined };
|
||||
expect(reduceInstallJob(noRuntime, EVENT.use)).toBe(noRuntime);
|
||||
});
|
||||
|
||||
it('every event but remove carries the library', () => {
|
||||
const job = SAMPLE.installing;
|
||||
expect(reduceInstallJob(job, EVENT.progress).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT['step-done']).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.fail).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.cancel).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.restart).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(SAMPLE.failed, EVENT.resume).library).toEqual([OTHER]);
|
||||
const last: LocalModelStateFile = { ...job, step: 'connect', library: [OTHER, ENTRY], connection: CONNECTION };
|
||||
expect(reduceInstallJob(last, EVENT['step-done'])).toMatchObject({ state: 'installed', library: [OTHER, ENTRY] });
|
||||
expect(reduceInstallJob(SAMPLE.installed, EVENT.remove)).toEqual({ schema: 1, state: 'idle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLibraryRows', () => {
|
||||
const tagOf = (entry: LocalModelLibraryEntry, extra: Partial<{ size: number; digest: string }> = {}) => ({
|
||||
name: entry.id,
|
||||
size: entry.bytes ?? 0,
|
||||
digest: entry.digest,
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('is empty in idle, whatever is live', () => {
|
||||
expect(buildLibraryRows(SAMPLE.idle, { ...LIVE, running: true, tags: [tagOf(OTHER)] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('the runtime stopped: the checked models as they were recorded, a switch, and no delete', () => {
|
||||
expect(buildLibraryRows(SAMPLE.installed, LIVE)).toEqual([ENTRY_ROW, OTHER_ROW]);
|
||||
// A listing that is still around from before the stop is not trusted.
|
||||
expect(buildLibraryRows(SAMPLE.installed, { ...LIVE, running: false, tags: [] })).toEqual([ENTRY_ROW, OTHER_ROW]);
|
||||
});
|
||||
|
||||
it('the runtime running: its sizes, and a delete for every model but the one in use', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY, { size: 23_105_800_000 }), tagOf(OTHER)],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ ...ENTRY_ROW, sizeBytes: 23_105_800_000 },
|
||||
{ ...OTHER_ROW, actions: ['use', 'delete'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the model in use comes first, wherever the list has it', () => {
|
||||
const state: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, ENTRY] };
|
||||
expect(buildLibraryRows(state, LIVE).map((row) => row.id)).toEqual([ENTRY.id, OTHER.id]);
|
||||
});
|
||||
|
||||
it('a checked model whose digest the runtime no longer reports: changed, never switched to, deletable', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'somethingelse' })],
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ id: OTHER.id, status: 'changed', verified: true, actions: ['delete'] });
|
||||
// The digest is compared without its prefix and its case, like everywhere else.
|
||||
const same = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'sha256:DEF456' })],
|
||||
});
|
||||
expect(same[1].status).toBe('ready');
|
||||
});
|
||||
|
||||
it('a checked model the runtime no longer lists: missing, with its recorded size, and its entry can be dropped', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, { ...LIVE, running: true, tags: [tagOf(ENTRY)] });
|
||||
expect(rows[1]).toEqual({ ...OTHER_ROW, status: 'missing', actions: ['delete'] });
|
||||
});
|
||||
|
||||
it('what the runtime lists beside the checked models: not checked, deletable, never switched to', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
labels: { 'mimo-v2.6:9b-q4_k_m': 'MiMo V2.6 9B' },
|
||||
tags: [
|
||||
tagOf(ENTRY),
|
||||
tagOf(OTHER),
|
||||
{ name: 'zeta:1b', size: 900_000_000, digest: 'z' },
|
||||
{ name: 'mimo-v2.6:9b-q4_k_m', size: 6_550_811_033, digest: 'm' },
|
||||
],
|
||||
});
|
||||
expect(rows.slice(2)).toEqual([
|
||||
{
|
||||
id: 'mimo-v2.6:9b-q4_k_m',
|
||||
// The catalogue knows it, so it is named as the chooser names it.
|
||||
label: 'MiMo V2.6 9B',
|
||||
sizeBytes: 6_550_811_033,
|
||||
sizeGb: '6.6',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
},
|
||||
{
|
||||
id: 'zeta:1b',
|
||||
label: 'zeta:1b',
|
||||
sizeBytes: 900_000_000,
|
||||
sizeGb: '0.9',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a name that is not a plain name:tag is listed with its size and offers nothing', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), { name: 'hf.co/someone/model:Q4', size: 1_000_000_000, digest: 'h' }],
|
||||
});
|
||||
expect(rows.find((row) => row.id === 'hf.co/someone/model:Q4')).toMatchObject({
|
||||
status: 'unverified',
|
||||
sizeGb: '1.0',
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('the model of a stopped job: listed once it is on disk, and neither switched to nor deleted', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.failed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(OTHER), { name: MODEL.id, size: 23_105_798_956, digest: 'abc123' }],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ ...OTHER_ROW, actions: ['use', 'delete'] },
|
||||
{
|
||||
id: MODEL.id,
|
||||
label: MODEL.label,
|
||||
sizeBytes: 23_105_798_956,
|
||||
sizeGb: '23.1',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('while a job runs nothing is offered on any row', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installing, { ...LIVE, running: true, tags: [tagOf(OTHER)] });
|
||||
expect(rows.map((row) => row.actions)).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('a row without a known size has neither size member', () => {
|
||||
const state: LocalModelStateFile = { ...SAMPLE.paused, library: [{ ...OTHER, bytes: undefined }] };
|
||||
const [row] = buildLibraryRows(state, LIVE);
|
||||
expect(row).not.toHaveProperty('sizeBytes');
|
||||
expect(row).not.toHaveProperty('sizeGb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markLibraryCandidates', () => {
|
||||
const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0];
|
||||
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true });
|
||||
|
||||
it('marks the model in use and a model a switch can take, at the context they were checked with', () => {
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(MODEL.id, MODEL.context),
|
||||
alternatives: [candidate(OTHER.id, OTHER.context), candidate('gpt-oss:20b', 65536)],
|
||||
} as unknown as Recommendation;
|
||||
const marked = markLibraryCandidates(recommendation, view);
|
||||
expect(marked.pick?.library).toBe('active');
|
||||
expect(marked.alternatives[0].library).toBe('ready');
|
||||
expect(marked.alternatives[1]).not.toHaveProperty('library');
|
||||
// The input is not written to.
|
||||
expect(recommendation.pick).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('a row at another context than the one that was checked is an install like any other', () => {
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(MODEL.id, 65536),
|
||||
alternatives: [candidate(OTHER.id, 131072)],
|
||||
} as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(recommendation, view)).toBe(recommendation);
|
||||
});
|
||||
|
||||
it('hands back the same object when there is nothing to mark', () => {
|
||||
const none = { verdict: 'none', alternatives: [] } as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(none, view)).toBe(none);
|
||||
const idle = buildInstallView(SAMPLE.idle, LIVE);
|
||||
const recommendation = { verdict: 'ok', pick: candidate(MODEL.id, MODEL.context), alternatives: [] };
|
||||
expect(markLibraryCandidates(recommendation as unknown as Recommendation, idle)).toBe(recommendation);
|
||||
});
|
||||
|
||||
it('a checked model that changed is not marked ready', () => {
|
||||
const changed = buildInstallView(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [
|
||||
{ name: MODEL.id, size: 1, digest: 'abc123' },
|
||||
{ name: OTHER.id, size: 1, digest: 'other' },
|
||||
],
|
||||
});
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(OTHER.id, OTHER.context),
|
||||
alternatives: [],
|
||||
} as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(recommendation, changed).pick).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInstallMessage', () => {
|
||||
it('fills the slots of a sentence', () => {
|
||||
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe(
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* Cancel in every step; Resume; a job a server restart interrupted comes back paused and
|
||||
* waits for a click; every failure is a state with a code and a next step; the coding agent
|
||||
* install (the stock command, the audit line, the resolver caches forgotten, still missing is
|
||||
* a failure); the digest that refuses a changed model; the runtime verbs; Remove.
|
||||
* a failure); the digest that refuses a changed model; the runtime verbs; Remove; and the
|
||||
* library: a second model beside the first, the switch between checked models, the delete of
|
||||
* one model, what a stopped job leaves to go back to, and the two cached listings of the view.
|
||||
*
|
||||
* Port: N/A (nothing listens).
|
||||
*/
|
||||
@@ -259,6 +261,10 @@ function setup(
|
||||
running: false,
|
||||
gaveUp: false,
|
||||
pid: 5000,
|
||||
/** What the runtime lists on disk. Default: the model of the state, as it was pulled. */
|
||||
tags: undefined as { name: string; size: number; digest: string }[] | undefined,
|
||||
/** What the model store takes on disk. */
|
||||
storeBytes: 0,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -325,14 +331,20 @@ function setup(
|
||||
{ name: 'qwen3.6:35b-a3b', size: 26_300_000_000, sizeVram: 26_300_000_000, contextLength: 32768 },
|
||||
]),
|
||||
getRuntimeVersion: vi.fn(async () => '0.40.15' as string | undefined),
|
||||
deleteModel: vi.fn(async () => {
|
||||
deleteModel: vi.fn(async (_target: unknown, _id: string) => {
|
||||
calls.push('deleteModel');
|
||||
return true;
|
||||
}),
|
||||
listTags: vi.fn(async () => {
|
||||
if (world.tags) return world.tags;
|
||||
const model = store.get().model;
|
||||
return model ? [{ name: model.id, size: PULL_BYTES, digest: DIGEST }] : [];
|
||||
}),
|
||||
removePartialModelBlobs: vi.fn(async () => {
|
||||
calls.push('removePartialModelBlobs');
|
||||
return 0;
|
||||
}),
|
||||
measureModelStore: vi.fn(async () => world.storeBytes),
|
||||
checkDeveloperTools: vi.fn(async () => 'missing' as const),
|
||||
stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)),
|
||||
tryBeginCliInstall: vi.fn((): (() => void) | null => () => {
|
||||
@@ -470,7 +482,7 @@ describe('a job from the click to installed', () => {
|
||||
port: 11435,
|
||||
loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true },
|
||||
endpointId: 'local-ollama',
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
folder: '/data/local-model',
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0 },
|
||||
});
|
||||
@@ -699,6 +711,8 @@ describe('one job at a time', () => {
|
||||
const jobRunning = { ok: false, status: 409, conflict: { reason: 'job-running' } };
|
||||
expect(await h.service.install(REQUEST, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.resume({}, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.use({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.deleteLibraryModel({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.runtime({ action: 'stop' })).toMatchObject(jobRunning);
|
||||
expect(await h.service.remove({}, [])).toMatchObject(jobRunning);
|
||||
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
|
||||
@@ -718,7 +732,7 @@ describe('one job at a time', () => {
|
||||
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('install while a model is installed is 409 already-installed: remove it first', async () => {
|
||||
it('an install of the model that is in use, at its context, is 409 already-installed', async () => {
|
||||
const h = setup({ state: INSTALLED });
|
||||
expect(await h.service.install(REQUEST, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
@@ -1073,7 +1087,11 @@ describe('boot with a model installed', () => {
|
||||
// The pid of the child that runs now replaces the stored one.
|
||||
expect(h.store.get().runtime?.pid).toBe(5000);
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(view).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
@@ -1107,7 +1125,7 @@ describe('boot with a model installed', () => {
|
||||
await h.service.boot();
|
||||
expect(h.supervisor.start).not.toHaveBeenCalled();
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
|
||||
// A stopped runtime is not an error.
|
||||
expect(view).not.toHaveProperty('error');
|
||||
});
|
||||
@@ -1124,7 +1142,7 @@ describe('boot with a model installed', () => {
|
||||
message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' },
|
||||
next: 'remove',
|
||||
});
|
||||
expect(view.actions).toEqual(['stop', 'remove']);
|
||||
expect(view.actions).toEqual(['stop', 'install', 'remove']);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
expect(h.store.get()).not.toHaveProperty('error');
|
||||
});
|
||||
@@ -1140,7 +1158,7 @@ describe('boot with a model installed', () => {
|
||||
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
|
||||
next: 'other-port',
|
||||
});
|
||||
expect(view.actions).toEqual(['start', 'remove']);
|
||||
expect(view.actions).toEqual(['start', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('never throws, whatever the runtime does', async () => {
|
||||
@@ -1536,7 +1554,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
|
||||
'The model on this Mac changed since it was installed. Remove it and install it again.'
|
||||
);
|
||||
// And the window stops offering a session.
|
||||
expect((await h.service.view()).actions).toEqual(['stop', 'remove']);
|
||||
expect((await h.service.view()).actions).toEqual(['stop', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('a digest with the sha256 prefix some listings add is the same digest', async () => {
|
||||
@@ -1602,7 +1620,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
|
||||
it('only the model that was installed and checked may be started on the managed endpoint', async () => {
|
||||
const h = await installed();
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', 'someone-else:7b')).toBe(
|
||||
'Only Qwen3.6 35B-A3B is installed on this Mac. Remove it and install another model first.'
|
||||
'Only Qwen3.6 35B-A3B is in use on this Mac. Switch models in the local model window first.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1641,7 +1659,7 @@ describe('the runtime verbs', () => {
|
||||
expect(stopped.ok && stopped.view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: false,
|
||||
actions: ['start', 'remove'],
|
||||
actions: ['start', 'install', 'remove'],
|
||||
});
|
||||
expect(h.supervisor.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.store.get().runtime?.enabled).toBe(false);
|
||||
@@ -1652,7 +1670,10 @@ describe('the runtime verbs', () => {
|
||||
expect(next.supervisor.start).not.toHaveBeenCalled();
|
||||
|
||||
const started = await h.service.runtime({ action: 'start' });
|
||||
expect(started.ok && started.view).toMatchObject({ running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(started.ok && started.view).toMatchObject({
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(h.store.get().runtime?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1747,10 +1768,13 @@ describe('Remove', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/data/local-model',
|
||||
library: [],
|
||||
// Measured again after the Remove: the store is empty here.
|
||||
store: { bytes: 0, gb: '0.0' },
|
||||
actions: ['install'],
|
||||
});
|
||||
expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' });
|
||||
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded'])
|
||||
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded', 'library'])
|
||||
expect(h.store.get()).not.toHaveProperty(key);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
@@ -2342,7 +2366,7 @@ describe('an installed model whose runtime does not start', () => {
|
||||
h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' };
|
||||
await h.service.boot();
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
|
||||
expect(view.error).toEqual({
|
||||
code: 'runtime-start-failed',
|
||||
message: { text: 'The runtime did not start on this Mac.' },
|
||||
@@ -2396,7 +2420,11 @@ describe('a later build with another runtime pin', () => {
|
||||
// The stored version stays what runs, so the next start finds it again.
|
||||
expect(h.store.get().runtime?.version).toBe(OLD);
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(view.error).toBeUndefined();
|
||||
// A NEW install would still download the pinned build.
|
||||
expect(view.runtime).toMatchObject({ version: OLLAMA_RUNTIME_PIN.version, installed: false });
|
||||
@@ -2639,3 +2667,663 @@ describe('Stop and "Use another port" while live sessions use the model', () =>
|
||||
expect(h.store.get().runtime?.port).toBe(11435);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The library ----------
|
||||
|
||||
const GPT = { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 131072 };
|
||||
const GPT_BYTES = 13_793_441_427;
|
||||
const QWEN_ENTRY = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
digest: DIGEST,
|
||||
bytes: PULL_BYTES,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
};
|
||||
const GPT_ENTRY = {
|
||||
id: GPT.id,
|
||||
label: GPT.label,
|
||||
context: 65536,
|
||||
digest: DIGEST,
|
||||
bytes: GPT_BYTES,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
};
|
||||
/** Two checked models, the Qwen one in use. */
|
||||
const TWO_MODELS: LocalModelStateFile = { ...INSTALLED, library: [QWEN_ENTRY, GPT_ENTRY] };
|
||||
const BOTH_TAGS = [
|
||||
{ name: QWEN_ENTRY.id, size: PULL_BYTES, digest: DIGEST },
|
||||
{ name: GPT_ENTRY.id, size: GPT_BYTES, digest: DIGEST },
|
||||
];
|
||||
const ADD_GPT: LocalModelInstallRequest = {
|
||||
modelId: GPT.id,
|
||||
cli: 'opencode',
|
||||
context: GPT.context,
|
||||
acknowledgedBytes: GPT_BYTES + RUNTIME_BYTES,
|
||||
};
|
||||
|
||||
describe('the first install starts the library', () => {
|
||||
it('the model that passed its check is the one checked model, with its digest, size and coding agent', async () => {
|
||||
const h = setup();
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get().library).toEqual([{ ...QWEN_ENTRY, verifiedAt: new Date(1_000_000).toISOString() }]);
|
||||
const view = await h.service.view();
|
||||
expect(view.library).toEqual([
|
||||
{
|
||||
id: QWEN_ENTRY.id,
|
||||
label: QWEN_ENTRY.label,
|
||||
context: 131072,
|
||||
sizeBytes: PULL_BYTES,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('the entry is written when the check passes, before the coding agent is installed', async () => {
|
||||
const h = setup({
|
||||
overrides: {
|
||||
runInstallCommand: vi.fn(async () => ({ code: 1, output: 'no network\n', timedOut: false, aborted: false })),
|
||||
},
|
||||
});
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'agent', library: [{ id: QWEN_ENTRY.id }] });
|
||||
// It is the model of the stopped job: Resume goes on with it, so no switch is offered.
|
||||
const view = await h.service.view();
|
||||
expect(view.library.map((row) => row.actions)).toEqual([[]]);
|
||||
expect(view.actions).toEqual(['resume', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('a size the runtime does not report falls back to what the pull counted', async () => {
|
||||
const h = setup();
|
||||
h.world.tags = [];
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get().library?.[0].bytes).toBe(PULL_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding a model to an installed one', () => {
|
||||
it('downloads, checks and connects it, and keeps the earlier model in the library', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
|
||||
const started = await h.service.install(ADD_GPT, ADMIN);
|
||||
expect(started.ok && started.view.state).toBe('installing');
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'ensureRuntimeInstalled',
|
||||
'supervisor.start',
|
||||
'supervisor.start',
|
||||
'pullModel',
|
||||
'supervisor.start',
|
||||
'verifyModel',
|
||||
'supervisor.start',
|
||||
'connectLocalModel',
|
||||
]);
|
||||
// Nothing of the earlier model was deleted: it was checked, and it stays.
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.removePartialModelBlobs).not.toHaveBeenCalled();
|
||||
// The runtime is started for the new model's context.
|
||||
expect(h.supervisor.start).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ port: 11435, contextLength: 131072 })
|
||||
);
|
||||
// The one endpoint record is rewritten for the new model; it stays ours.
|
||||
expect(h.deps.connectLocalModel).toHaveBeenCalledWith({
|
||||
port: 11435,
|
||||
modelId: GPT.id,
|
||||
context: GPT.context,
|
||||
ours: true,
|
||||
flagWasEnabled: false,
|
||||
});
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: GPT.id, context: GPT.context, digest: DIGEST },
|
||||
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
||||
library: [QWEN_ENTRY, { id: GPT.id, context: GPT.context, digest: DIGEST, bytes: GPT_BYTES }],
|
||||
});
|
||||
expect(h.store.get()).not.toHaveProperty('replacedModelId');
|
||||
|
||||
const view = await h.service.view();
|
||||
expect(view.model).toEqual({ id: GPT.id, label: GPT.label });
|
||||
expect(view.library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
|
||||
[GPT.id, true, 'ready', []],
|
||||
[QWEN_ENTRY.id, false, 'ready', ['use', 'delete']],
|
||||
]);
|
||||
expect(view.actions).toEqual(['start-session', 'stop', 'install', 'use', 'delete-model', 'remove']);
|
||||
// Only the model in use may be launched.
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', GPT.id)).toBeUndefined();
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
|
||||
'Only GPT-OSS 20B is in use on this Mac. Switch models in the local model window first.'
|
||||
);
|
||||
});
|
||||
|
||||
it('is refused while live sessions use the model, before anything is asked or started', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
const refused = await h.service.install(ADD_GPT, ADMIN, ['w1-app', 'w2-api']);
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'sessions-open',
|
||||
sessions: ['w1-app', 'w2-api'],
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
|
||||
},
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.deps.readHardware).not.toHaveBeenCalled();
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('a first install is not held up by sessions elsewhere: no connection of ours exists yet', async () => {
|
||||
const h = setup();
|
||||
const started = await h.service.install(REQUEST, ADMIN, ['w1-app']);
|
||||
expect(started.ok).toBe(true);
|
||||
await h.service.cancel();
|
||||
});
|
||||
|
||||
it('the model in use at ANOTHER context is an install like any other: it is checked again at that context', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
h.deps.recommend.mockReturnValue({
|
||||
...recommendation(true),
|
||||
pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 65536, PULL_BYTES, true),
|
||||
});
|
||||
await h.service.boot();
|
||||
const started = await h.service.install({ ...REQUEST, context: 65536 }, ADMIN);
|
||||
expect(started.ok).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: 'qwen3.6:35b-a3b', context: 65536 },
|
||||
library: [{ id: 'qwen3.6:35b-a3b', context: 65536 }],
|
||||
});
|
||||
expect(h.store.get().library).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a job that stops leaves the earlier model to go back to, and no session until then', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
h.deps.verifyModel.mockResolvedValueOnce({ ok: false, code: 'no-tool-call' } as never);
|
||||
await h.service.install(ADD_GPT, ADMIN);
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'verify', model: { id: GPT.id } });
|
||||
// The endpoint record still names the earlier model, and nothing may be started on it.
|
||||
expect(await h.service.launchRefusal('local-ollama')).toEqual({
|
||||
code: 'job-unfinished',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished },
|
||||
next: 'retry',
|
||||
});
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
|
||||
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.'
|
||||
);
|
||||
const view = await h.service.view();
|
||||
expect(view.actions).toEqual(['resume', 'install', 'use', 'delete-model', 'remove']);
|
||||
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
|
||||
// Downloaded and not checked: the model of the stopped job, so nothing is offered on it.
|
||||
[GPT.id, 'unverified', []],
|
||||
]);
|
||||
|
||||
// The way back. The model that never passed its check goes: it is what the job left behind.
|
||||
h.calls.length = 0;
|
||||
const back = await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
expect(back.ok).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
|
||||
expect(h.calls).toContain('removePartialModelBlobs');
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id, digest: DIGEST } });
|
||||
expect(h.store.get()).not.toHaveProperty('replacedModelId');
|
||||
expect(h.store.get()).not.toHaveProperty('error');
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('use: the switch to a checked model', () => {
|
||||
it('loads and checks it again, connects it, and downloads nothing', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
|
||||
const started = await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
expect(started.ok && started.view).toMatchObject({
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: GPT.id, label: GPT.label },
|
||||
context: 65536,
|
||||
});
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'recheckCliInstalled',
|
||||
'supervisor.start',
|
||||
'verifyModel',
|
||||
'supervisor.start',
|
||||
'connectLocalModel',
|
||||
]);
|
||||
expect(h.deps.pullModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled();
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
// The runtime is restarted for the context the model was checked with.
|
||||
expect(h.supervisor.start).toHaveBeenLastCalledWith(expect.objectContaining({ contextLength: 65536 }));
|
||||
expect(h.deps.verifyModel).toHaveBeenCalledWith(expect.anything(), GPT.id, 65536, expect.anything());
|
||||
expect(h.deps.connectLocalModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modelId: GPT.id, context: 65536, ours: true })
|
||||
);
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: GPT.id, context: 65536, digest: DIGEST },
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
library: [QWEN_ENTRY, { id: GPT.id, digest: DIGEST }],
|
||||
});
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
action: 'local_model_use',
|
||||
target: GPT.id,
|
||||
ip: '127.0.0.1',
|
||||
detail: { cli: 'opencode', context: 65536 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is refused for a model that is not one of the checked ones, and for the one in use', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
expect(await h.service.use({ modelId: 'someone-else:7b' }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
|
||||
});
|
||||
expect(await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'already-installed' },
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('is refused while live sessions use the model', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN, ['w1-app'])).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'sessions-open', sessions: ['w1-app'] },
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('is 409 wrong-state where there is nothing to switch: idle', async () => {
|
||||
const h = setup();
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'wrong-state' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a model whose digest is no longer the stored one is not loaded: model-changed, and the way back stays', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = [BOTH_TAGS[0], { ...BOTH_TAGS[1], digest: 'ffff00000000' }];
|
||||
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
|
||||
id === GPT.id ? 'ffff00000000' : DIGEST
|
||||
);
|
||||
h.calls.length = 0;
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.deps.verifyModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.connectLocalModel).not.toHaveBeenCalled();
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('failed');
|
||||
expect(view.error).toMatchObject({
|
||||
code: 'model-changed',
|
||||
detail: 'The runtime reports another digest for the model.',
|
||||
});
|
||||
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
|
||||
[GPT.id, 'changed', []],
|
||||
]);
|
||||
// The changed model stays a library entry until the person deletes it; it is never deleted as a leftover.
|
||||
await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id } });
|
||||
});
|
||||
|
||||
it('a model the runtime no longer lists is model-changed too, said as that', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
|
||||
id === GPT.id ? undefined : DIGEST
|
||||
);
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
expect((await h.service.view()).error).toMatchObject({
|
||||
code: 'model-changed',
|
||||
detail: 'The runtime no longer lists the model.',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the coding agent the model was checked with when it is installed, else the one of the state', async () => {
|
||||
const pi = { id: 'pi', label: 'Pi' };
|
||||
const state: LocalModelStateFile = { ...TWO_MODELS, library: [QWEN_ENTRY, { ...GPT_ENTRY, cli: pi }] };
|
||||
const withPi = setup({ state, runtimeOnDisk: true });
|
||||
withPi.deps.recheckCliInstalled.mockImplementation(async () => true);
|
||||
await withPi.service.boot();
|
||||
await withPi.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await withPi.service.idle();
|
||||
expect(withPi.store.get().cli).toEqual(pi);
|
||||
|
||||
const withoutPi = setup({ state, runtimeOnDisk: true });
|
||||
withoutPi.deps.recheckCliInstalled.mockImplementation((async (id: string) => id === 'opencode') as never);
|
||||
await withoutPi.service.boot();
|
||||
await withoutPi.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await withoutPi.service.idle();
|
||||
expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' });
|
||||
});
|
||||
|
||||
it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
|
||||
await h.service.boot();
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentMissing, vars: { cli: 'OpenCode' } },
|
||||
});
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('Cancel pauses a switch like any job, and Resume goes on with it', async () => {
|
||||
const h = setup({
|
||||
state: TWO_MODELS,
|
||||
runtimeOnDisk: true,
|
||||
agentInstalled: true,
|
||||
overrides: {
|
||||
verifyModel: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async (_t: unknown, _id: string, _c: number, signal?: AbortSignal) => {
|
||||
await untilAborted(signal);
|
||||
return { ok: false as const, code: 'cancelled' as const };
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true as const,
|
||||
loaded: { context: 65536, sizeBytes: 15_000_000_000, sizeVramBytes: 15_000_000_000 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await h.service.boot();
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
|
||||
const paused = await h.service.cancel();
|
||||
expect(paused.ok && paused.view).toMatchObject({ state: 'paused', step: 'verify', steps: ['verify', 'connect'] });
|
||||
await h.service.resume({}, ADMIN);
|
||||
await h.service.idle();
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLibraryModel: one model out of the store', () => {
|
||||
async function twoModelsRunning() {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = [...BOTH_TAGS];
|
||||
h.deps.deleteModel.mockImplementation(async (_target: unknown, id: string) => {
|
||||
h.calls.push('deleteModel');
|
||||
h.world.tags = (h.world.tags ?? []).filter((tag) => tag.name !== id);
|
||||
return true;
|
||||
});
|
||||
h.calls.length = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
it('deletes it through the runtime, drops its entry and writes the audit line', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledTimes(1);
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith({ baseUrl: 'http://127.0.0.1:11435' }, GPT.id, expect.anything());
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
// The answer is the view after the delete: the runtime was asked again.
|
||||
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id]);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
action: 'local_model_delete',
|
||||
target: GPT.id,
|
||||
ip: '127.0.0.1',
|
||||
detail: { verified: true, bytes: GPT_BYTES },
|
||||
});
|
||||
// Nothing else of the feature was touched.
|
||||
expect(h.calls).toEqual(['deleteModel']);
|
||||
});
|
||||
|
||||
it('refuses the model in use: switch first, or remove', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
expect(await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.6 35B-A3B' } },
|
||||
},
|
||||
});
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses while the runtime is down or does not answer: only the runtime deletes a model', async () => {
|
||||
const stopped = setup({ state: TWO_MODELS });
|
||||
expect(await stopped.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'runtime-not-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning } },
|
||||
});
|
||||
const silent = await twoModelsRunning();
|
||||
silent.deps.getRuntimeVersion.mockResolvedValue(undefined);
|
||||
expect(await silent.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'runtime-not-running' },
|
||||
});
|
||||
expect(silent.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(silent.store.get().library).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses a name that is neither a checked model nor one the runtime lists, and anything that is not name:tag', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
const refused = { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } };
|
||||
expect(await h.service.deleteLibraryModel({ modelId: 'someone-else:7b' }, ADMIN)).toEqual(refused);
|
||||
for (const modelId of ['../../etc/passwd', '/Users/me/models', 'hf.co/someone/model:Q4', 'NoTag', '']) {
|
||||
expect(await h.service.deleteLibraryModel({ modelId }, ADMIN)).toEqual(refused);
|
||||
}
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is 409 wrong-state in idle: there is no runtime to ask', async () => {
|
||||
const h = setup();
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'wrong-state' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a runtime that does not delete it: said as that, and the entry stays', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.deps.deleteModel.mockResolvedValue(false);
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 500,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
|
||||
});
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
|
||||
expect(h.deps.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a model the runtime lists and Codeman never checked can be deleted too', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.world.tags = [...BOTH_TAGS, { name: 'leftover:9b', size: 6_000_000_000, digest: 'aa' }];
|
||||
const before = await h.service.view();
|
||||
expect(before.library.find((row) => row.id === 'leftover:9b')).toMatchObject({
|
||||
status: 'unverified',
|
||||
sizeGb: '6.0',
|
||||
actions: ['delete'],
|
||||
});
|
||||
const result = await h.service.deleteLibraryModel({ modelId: 'leftover:9b' }, ADMIN);
|
||||
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'local_model_delete', detail: { verified: false, bytes: 6_000_000_000 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('a checked model the runtime no longer lists: its entry is dropped once the runtime says it is gone', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.world.tags = [BOTH_TAGS[0]];
|
||||
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
|
||||
expect(result.ok).toBe(true);
|
||||
// Asked of the runtime all the same: its "not found" is what says the model is gone.
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
|
||||
});
|
||||
|
||||
it('works from a stopped job too, for any model but the one of that job', async () => {
|
||||
const state: LocalModelStateFile = {
|
||||
...jobAt('verify', 'paused'),
|
||||
model: { ...GPT },
|
||||
library: [QWEN_ENTRY],
|
||||
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
||||
};
|
||||
const h = setup({ state, runtimeOnDisk: true });
|
||||
h.world.running = true;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
conflict: { reason: 'model-active' },
|
||||
});
|
||||
const result = await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(h.store.get().library).toEqual([]);
|
||||
expect(h.store.get().state).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the model store in idle: what an earlier Remove kept', () => {
|
||||
it('shows what it takes, offers Remove, and Remove with the models frees it', async () => {
|
||||
const h = setup();
|
||||
h.world.storeBytes = PULL_BYTES;
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({
|
||||
state: 'idle',
|
||||
library: [],
|
||||
store: { bytes: PULL_BYTES, gb: '23.1' },
|
||||
actions: ['install', 'remove'],
|
||||
});
|
||||
|
||||
h.deps.removeLocalModelFiles.mockImplementation(async () => {
|
||||
h.calls.push('removeLocalModelFiles');
|
||||
h.world.storeBytes = 0;
|
||||
});
|
||||
const removed = await h.service.remove({ removeModel: true }, [], ADMIN);
|
||||
expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS });
|
||||
expect(removed.ok && removed.view).toMatchObject({
|
||||
state: 'idle',
|
||||
store: { bytes: 0, gb: '0.0' },
|
||||
actions: ['install'],
|
||||
});
|
||||
});
|
||||
|
||||
it('Remove that keeps the models leaves them there: the next view says how much', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
h.world.storeBytes = PULL_BYTES + GPT_BYTES;
|
||||
const removed = await h.service.remove({}, [], ADMIN);
|
||||
expect(removed.ok && removed.view).toMatchObject({
|
||||
state: 'idle',
|
||||
library: [],
|
||||
store: { bytes: PULL_BYTES + GPT_BYTES, gb: '36.9' },
|
||||
actions: ['install', 'remove'],
|
||||
});
|
||||
expect(h.store.get()).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the view does not ask the runtime or the disk on every poll', () => {
|
||||
it('reads the list and the store once, then again only when they are stale', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
h.deps.listTags.mockClear();
|
||||
for (let i = 0; i < 5; i++) await h.service.view();
|
||||
expect(h.deps.listTags).toHaveBeenCalledTimes(1);
|
||||
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Five seconds on: the list is read again, in the background. The view does not wait for
|
||||
// a runtime that is slow to answer: it is built from the list it has.
|
||||
h.world.clock += 5_000;
|
||||
let answer: (tags: typeof BOTH_TAGS) => void = () => {};
|
||||
h.deps.listTags.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const stale = await h.service.view();
|
||||
expect(stale.library.map((row) => row.status)).toEqual(['ready', 'ready']);
|
||||
await vi.waitFor(() => expect(h.deps.listTags).toHaveBeenCalledTimes(2));
|
||||
// A second poll while that read is out starts no second one.
|
||||
await h.service.view();
|
||||
expect(h.deps.listTags).toHaveBeenCalledTimes(2);
|
||||
answer([BOTH_TAGS[0]]);
|
||||
await vi.waitFor(async () =>
|
||||
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'missing'])
|
||||
);
|
||||
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Thirty seconds on: the store is measured again.
|
||||
h.world.clock += 30_000;
|
||||
await h.service.view();
|
||||
await vi.waitFor(() => expect(h.deps.measureModelStore).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('a runtime that does not answer keeps the last list: no model reads as missing for a moment', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
await h.service.view();
|
||||
h.deps.getRuntimeVersion.mockResolvedValue(undefined);
|
||||
h.deps.listTags.mockClear();
|
||||
h.world.clock += 5_000;
|
||||
await h.service.view();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(h.deps.listTags).not.toHaveBeenCalled();
|
||||
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'ready']);
|
||||
});
|
||||
|
||||
it('a stopped runtime is not asked at all, and offers no delete', async () => {
|
||||
const h = setup({ state: { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } } });
|
||||
await h.service.boot();
|
||||
const view = await h.service.view();
|
||||
expect(h.deps.listTags).not.toHaveBeenCalled();
|
||||
expect(view.library.map((row) => [row.id, row.sizeGb, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, '23.1', []],
|
||||
[GPT.id, '13.8', ['use']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a listing or a measurement that fails is not an error of the view', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.deps.listTags.mockRejectedValue(new Error('socket hang up'));
|
||||
h.deps.measureModelStore.mockRejectedValue(new Error('EIO'));
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('installed');
|
||||
expect(view).not.toHaveProperty('store');
|
||||
expect(view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
OLLAMA_RUNTIME_PIN,
|
||||
parseInferenceComputeTotal,
|
||||
removeLocalModelFiles,
|
||||
measureModelStore,
|
||||
removePartialModelBlobs,
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_DOWNLOAD_RETRIES,
|
||||
@@ -1103,3 +1104,56 @@ describe('removePartialModelBlobs', () => {
|
||||
expect(existsSync(join(outside, 'sha256-eee-partial'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureModelStore', () => {
|
||||
const blob = (name: string): string => join(paths.modelsDir, 'blobs', name);
|
||||
|
||||
it('is the sizes of the files in the blob folder, a download that was interrupted included', async () => {
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
await mkdir(join(paths.modelsDir, 'manifests', 'registry.ollama.ai'), { recursive: true });
|
||||
await writeFile(blob('sha256-aaa'), 'x'.repeat(1000));
|
||||
await writeFile(blob('sha256-bbb'), 'y'.repeat(250));
|
||||
await writeFile(blob('sha256-ccc-partial'), 'z'.repeat(40));
|
||||
// The manifests are a few kilobytes beside gigabytes of layers: left out.
|
||||
await writeFile(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'), '{"layers":[]}');
|
||||
expect(await measureModelStore(paths)).toBe(1290);
|
||||
});
|
||||
|
||||
it('is 0 without a model store, without a blob folder and for an empty one, and never throws', async () => {
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
await mkdir(paths.modelsDir, { recursive: true });
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing and changes nothing: the store is as it was afterwards', async () => {
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
await writeFile(blob('sha256-aaa'), 'weights');
|
||||
await writeFile(blob('sha256-bbb-partial'), 'half');
|
||||
await measureModelStore(paths);
|
||||
expect(await readFile(blob('sha256-aaa'), 'utf8')).toBe('weights');
|
||||
expect(await readFile(blob('sha256-bbb-partial'), 'utf8')).toBe('half');
|
||||
});
|
||||
|
||||
it('counts only regular files directly in the blob folder, and follows no link', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'huge'), 'h'.repeat(5000));
|
||||
await mkdir(join(paths.modelsDir, 'blobs', 'a-folder'), { recursive: true });
|
||||
await writeFile(join(paths.modelsDir, 'blobs', 'a-folder', 'inside'), 'i'.repeat(700));
|
||||
await symlink(join(outside, 'huge'), blob('sha256-link'));
|
||||
await writeFile(blob('sha256-aaa'), 'x'.repeat(10));
|
||||
// The folder's file and the link's target are somebody else's: not counted.
|
||||
expect(await measureModelStore(paths)).toBe(10);
|
||||
});
|
||||
|
||||
it('a blob folder, a model store or a root that is a symlink counts as empty', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'sha256-eee'), 'e'.repeat(900));
|
||||
await mkdir(paths.modelsDir, { recursive: true });
|
||||
await symlink(outside, join(paths.modelsDir, 'blobs'));
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
// A path that is not inside the local model folder is refused before anything is read.
|
||||
expect(await measureModelStore({ ...paths, modelsDir: outside })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,89 @@ describe('parseLocalModelState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLocalModelState: the library', () => {
|
||||
const QWEN = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
||||
bytes: 23_105_798_956,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
verifiedAt: '2026-11-10T09:00:00.000Z',
|
||||
};
|
||||
const MIMO = { id: 'mimo-v2.6:9b-q4_k_m', label: 'MiMo V2.6 9B', context: 65536, digest: 'abc123' };
|
||||
const read = (state: unknown) => parseLocalModelState(JSON.stringify(state));
|
||||
|
||||
it('reads back the checked models of an installed state and of a job, in their order', () => {
|
||||
expect(read({ ...INSTALLED, library: [QWEN, MIMO] })).toEqual({ ...INSTALLED, library: [QWEN, MIMO] });
|
||||
expect(read({ ...PAUSED, library: [MIMO] })).toEqual({ ...PAUSED, library: [MIMO] });
|
||||
expect(read({ ...FAILED, library: [MIMO, QWEN] }).library).toEqual([MIMO, QWEN]);
|
||||
});
|
||||
|
||||
it('a file an earlier build wrote has no list, and reads back without one', () => {
|
||||
expect(read(INSTALLED)).toEqual(INSTALLED);
|
||||
expect(read(INSTALLED)).not.toHaveProperty('library');
|
||||
expect(read(PAUSED)).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('an empty list stays an empty list: every checked model was deleted', () => {
|
||||
expect(read({ ...PAUSED, library: [] }).library).toEqual([]);
|
||||
});
|
||||
|
||||
it('a list that is not a list is dropped, the state is kept', () => {
|
||||
for (const library of ['qwen', 7, { 0: QWEN }, null, true]) {
|
||||
const state = read({ ...INSTALLED, library });
|
||||
expect(state.state).toBe('installed');
|
||||
expect(state).not.toHaveProperty('library');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops an entry that could never be switched to: without its id, label, context or digest', () => {
|
||||
const library = [
|
||||
{ ...QWEN, id: '' },
|
||||
{ ...QWEN, label: 7 },
|
||||
{ ...QWEN, context: 0 },
|
||||
{ ...QWEN, context: '131072' },
|
||||
{ ...QWEN, digest: undefined },
|
||||
'qwen3.6:35b-a3b',
|
||||
null,
|
||||
['qwen3.6:35b-a3b'],
|
||||
MIMO,
|
||||
];
|
||||
expect(read({ ...INSTALLED, library }).library).toEqual([MIMO]);
|
||||
});
|
||||
|
||||
it('keeps one entry per model: the first', () => {
|
||||
const library = [QWEN, { ...QWEN, context: 65536 }, MIMO];
|
||||
expect(read({ ...INSTALLED, library }).library).toEqual([QWEN, MIMO]);
|
||||
});
|
||||
|
||||
it('drops what it does not know inside an entry, and a member of the wrong type: an entry holds no path', () => {
|
||||
const library = [
|
||||
{
|
||||
...QWEN,
|
||||
path: '/Users/me/.codeman/local-model/models',
|
||||
blobs: ['/etc/passwd'],
|
||||
bytes: -5,
|
||||
cli: { id: 'opencode' },
|
||||
verifiedAt: 7,
|
||||
},
|
||||
];
|
||||
const [entry] = read({ ...INSTALLED, library }).library ?? [];
|
||||
expect(entry).toEqual({ id: QWEN.id, label: QWEN.label, context: QWEN.context, digest: QWEN.digest });
|
||||
expect(JSON.stringify(read({ ...INSTALLED, library }))).not.toContain('/');
|
||||
});
|
||||
|
||||
it('reads at most 64 entries of a list, however long it is', () => {
|
||||
const library = Array.from({ length: 500 }, (_unused, i) => ({ ...MIMO, id: `model-${i}:9b` }));
|
||||
expect(read({ ...INSTALLED, library }).library).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('an idle file has no list, whatever it says', () => {
|
||||
expect(read({ schema: 1, state: 'idle', library: [QWEN] })).toEqual(IDLE_LOCAL_MODEL_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLocalModelState', () => {
|
||||
it('is idle when the file is missing, and never throws', async () => {
|
||||
expect(existsSync(FILE())).toBe(false);
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
* the fill follows the selection;
|
||||
* - the selected row carries the feature's colour on its reading edge;
|
||||
* - the welcome button is really gone while it is `hidden`, and the window paints above App
|
||||
* Settings.
|
||||
* Settings;
|
||||
* - the models on this Mac: the sizes on disk end on one edge under their caption, a row's
|
||||
* buttons end on the list's edge, the sizes do not move when a row's Delete question opens,
|
||||
* a long list scrolls from its top (it is never cut off above the frame), and under 560px a
|
||||
* row is two lines with nothing running off the side.
|
||||
*
|
||||
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
|
||||
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
|
||||
@@ -124,10 +128,54 @@ const STATUS = {
|
||||
developerTools: 'present',
|
||||
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
|
||||
folder: '/Users/sam/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
},
|
||||
};
|
||||
|
||||
const libraryRow = (id: string, label: string, sizeGb: string, over: object = {}) => ({
|
||||
id,
|
||||
label,
|
||||
context: 131072,
|
||||
sizeBytes: Number(sizeGb) * 1e9,
|
||||
sizeGb,
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use', 'delete'],
|
||||
...over,
|
||||
});
|
||||
|
||||
/** An installed model with two more models beside it: one checked, one the runtime lists and Codeman never checked. */
|
||||
const INSTALLED = {
|
||||
...STATUS,
|
||||
install: {
|
||||
...STATUS.install,
|
||||
state: 'installed',
|
||||
running: true,
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
context: 131072,
|
||||
port: 11435,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
library: [
|
||||
libraryRow('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', '23.1', { active: true, actions: [] }),
|
||||
libraryRow('qwen3.8:27b-mtp-q4_k_m', 'Qwen3.8 27B', '17.7'),
|
||||
libraryRow('mimo-v2.6:9b-q4_k_m', 'MiMo V2.6 9B', '6.6', {
|
||||
context: undefined,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
}),
|
||||
],
|
||||
store: { bytes: 47400000000, gb: '47.4' },
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
||||
},
|
||||
};
|
||||
|
||||
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
|
||||
|
||||
/** What the page holds once the module ran on the stand-in class. */
|
||||
@@ -160,7 +208,7 @@ describe('the local model window (real Chromium)', () => {
|
||||
});
|
||||
|
||||
/** The real page at a width, the module loaded, the answer in, the window closed. */
|
||||
async function load(width: number, height = 900) {
|
||||
async function load(width: number, height = 900, answer: object = STATUS) {
|
||||
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
|
||||
await p.emulateMedia({ reducedMotion: 'reduce' });
|
||||
@@ -178,7 +226,7 @@ describe('the local model window (real Chromium)', () => {
|
||||
w.app = new w.CodemanApp();
|
||||
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
|
||||
await w.app.loadLocalModel();
|
||||
}, STATUS);
|
||||
}, answer);
|
||||
}
|
||||
|
||||
/** Two animation frames: whatever was just written is laid out and painted. */
|
||||
@@ -343,6 +391,125 @@ describe('the local model window (real Chromium)', () => {
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('the models on this Mac: sizes on one edge, a row one line, its buttons on the list edge', async () => {
|
||||
await load(1440, 900, INSTALLED);
|
||||
await open();
|
||||
const rows = await boxes('.lm-lib-row');
|
||||
expect(rows).toHaveLength(3);
|
||||
const names = await boxes('.lm-lib-name');
|
||||
const tags = await boxes('.lm-lib-tag');
|
||||
const sizes = await boxes('.lm-lib-size');
|
||||
const [caption] = await boxes('.lm-lib-caption-size');
|
||||
expect(same(sizes.map((box) => box.right))).toBe(true);
|
||||
expect(same([caption.right, sizes[0].right])).toBe(true);
|
||||
expect(same(names.map((box) => box.left))).toBe(true);
|
||||
expect(same(tags.map((box) => box.left))).toBe(true);
|
||||
// One line a row: the name, the tag, the size and the buttons share its middle.
|
||||
const middle = (box: Box) => (box.top + box.bottom) / 2;
|
||||
const actions = await boxes('.lm-lib-row .lm-lib-actions');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
|
||||
expect(Math.abs(middle(tags[i]) - middle(sizes[i]))).toBeLessThan(3);
|
||||
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
|
||||
expect(sizes[i].right).toBeLessThanOrEqual(actions[i].left + 0.6);
|
||||
}
|
||||
// The last button of a row ends where every line of the window ends: on "Add a model"'s edge.
|
||||
const rowButtons = await boxes('.lm-lib-row .lm-lib-actions button:last-child');
|
||||
const [add] = await boxes('.lm-lib-foot button');
|
||||
expect(same([...rowButtons.map((box) => box.right), add.right])).toBe(true);
|
||||
// The model in use carries the feature's colour on its reading edge; the others do not.
|
||||
const paint = await p.evaluate(() => {
|
||||
const probe = document.body.appendChild(document.createElement('i'));
|
||||
probe.style.color = 'var(--lm-brand)';
|
||||
const brand = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
const edge = (sel: string) => getComputedStyle(document.querySelector(sel)!).borderLeftColor;
|
||||
return { brand, on: edge('.lm-lib-row-on'), off: edge('.lm-lib-row:not(.lm-lib-row-on)') };
|
||||
});
|
||||
expect(paint.on).toBe(paint.brand);
|
||||
expect(paint.off).not.toBe(paint.brand);
|
||||
// The screen starts at the top of the frame, right under the header.
|
||||
const [body] = await boxes('#localModelWindowBody');
|
||||
const [title] = await boxes('.lm-title');
|
||||
expect(title.top - body.top).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('the Delete question opens under its row without moving the sizes', async () => {
|
||||
await load(1440, 900, INSTALLED);
|
||||
await open();
|
||||
const before = await boxes('.lm-lib-size');
|
||||
await p.click('.lm-lib-row:nth-of-type(3) .lm-lib-actions button:last-child');
|
||||
await painted();
|
||||
const [ask] = await boxes('.lm-lib-ask');
|
||||
const rows = await boxes('.lm-lib-row');
|
||||
const after = await boxes('.lm-lib-size');
|
||||
// Sideways nothing moved; the rows after the question moved down by it.
|
||||
expect(after.map((box) => Math.round(box.right))).toEqual(before.map((box) => Math.round(box.right)));
|
||||
expect(ask.top).toBeGreaterThanOrEqual(rows[1].bottom - 1);
|
||||
expect(rows[2].top).toBeGreaterThanOrEqual(ask.bottom - 1);
|
||||
// The row's own buttons are unseen while it is asked about, and the red button is the question's.
|
||||
const seen = await p.evaluate(() => ({
|
||||
away: getComputedStyle(document.querySelector('.lm-lib-actions-away')!).visibility,
|
||||
red: [...document.querySelectorAll('.local-model-window .wt-btn-danger')].map((el) => el.textContent),
|
||||
focus: document.activeElement?.textContent,
|
||||
}));
|
||||
expect(seen).toEqual({ away: 'hidden', red: ['Delete'], focus: 'Cancel' });
|
||||
});
|
||||
|
||||
it('a long list of models scrolls from its top: the first lines are never cut off above the frame', async () => {
|
||||
const many = Array.from({ length: 14 }, (_unused, i) => libraryRow(`model-${i}:9b`, `Model ${i}`, '6.6'));
|
||||
const answer = {
|
||||
...INSTALLED,
|
||||
install: { ...INSTALLED.install, library: [...INSTALLED.install.library, ...many] },
|
||||
};
|
||||
for (const [width, height] of [
|
||||
[1440, 900],
|
||||
[390, 844],
|
||||
]) {
|
||||
await load(width, height, answer);
|
||||
await open();
|
||||
const scroll = await p.evaluate(() => {
|
||||
const body = document.getElementById('localModelWindowBody')!;
|
||||
const title = body.querySelector('.lm-title')!.getBoundingClientRect();
|
||||
const box = body.getBoundingClientRect();
|
||||
return {
|
||||
overflows: body.scrollHeight > body.clientHeight,
|
||||
top: body.scrollTop,
|
||||
titleInside: title.top >= box.top - 0.5,
|
||||
sideways: body.scrollWidth - body.clientWidth,
|
||||
};
|
||||
});
|
||||
expect(scroll).toEqual({ overflows: true, top: 0, titleInside: true, sideways: 0 });
|
||||
// The buttons of the screen stay in the frame's foot, under the part that scrolls.
|
||||
const [foot] = await boxes('#localModelWindowFoot');
|
||||
const [win] = await boxes('.local-model-window');
|
||||
expect(foot.bottom).toBeLessThanOrEqual(win.bottom + 0.6);
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the models on this Mac under 560px: two lines a row, nothing off the side', async () => {
|
||||
await load(390, 844, INSTALLED);
|
||||
await open();
|
||||
const names = await boxes('.lm-lib-name');
|
||||
const tags = await boxes('.lm-lib-tag');
|
||||
const sizes = await boxes('.lm-lib-size');
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
// The name and its size, then the state and what can be done.
|
||||
expect(tags[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
|
||||
expect(sizes[i].top).toBeLessThan(names[i].bottom);
|
||||
expect(same([names[i].left, tags[i].left])).toBe(true);
|
||||
}
|
||||
expect(same(sizes.map((box) => box.right))).toBe(true);
|
||||
const buttons = await boxes('.lm-lib-row .lm-lib-actions button');
|
||||
for (const button of buttons) expect(button.height).toBeGreaterThanOrEqual(36);
|
||||
const overflow = await p.evaluate(() => {
|
||||
const body = document.getElementById('localModelWindowBody')!;
|
||||
return body.scrollWidth - body.clientWidth;
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('paints above App Settings when it is opened from there', async () => {
|
||||
await load(1440);
|
||||
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
|
||||
|
||||
+561
-2
@@ -18,6 +18,10 @@
|
||||
* screen, and a 409 `size-changed` shows the new total and sends that one next;
|
||||
* - the poll: once a second while the window is open, never after it closes, and a tick that
|
||||
* moved only the download leaves the buttons in place;
|
||||
* - the models on this Mac: the list with its sizes, the state of each row, "Use" (a switch
|
||||
* with no confirm screen), "Delete" (asked once, in the row), "Add a model" (the chooser,
|
||||
* where a row that is on this Mac already is switched to), the way back from a stopped job,
|
||||
* and what an earlier Remove left on disk;
|
||||
* - every payload string lands as text: an `<img onerror>` in a model, agent or chip name, an
|
||||
* error's detail, a session name, a folder or a command creates no element;
|
||||
* - a button exists only for an action the server lists.
|
||||
@@ -48,6 +52,7 @@ import type {
|
||||
LocalModelConflict,
|
||||
LocalModelErrorCode,
|
||||
LocalModelInstallView,
|
||||
LocalModelLibraryRow,
|
||||
LocalModelMessage,
|
||||
LocalModelStatus,
|
||||
NoneReason,
|
||||
@@ -383,11 +388,26 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
|
||||
developerTools: 'present',
|
||||
runtime: RUNTIME_MISSING,
|
||||
folder: FOLDER,
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The installed model as the row of the library it is: in use, checked, with nothing to do on it. */
|
||||
const QWEN_ROW: LocalModelLibraryRow = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
sizeBytes: 23105798956,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
};
|
||||
const STORE_ONE = { bytes: 23105798956, gb: '23.1' };
|
||||
|
||||
/** The members a job carries from `installing` on. */
|
||||
const JOB: Partial<LocalModelInstallView> = {
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
@@ -453,7 +473,9 @@ const INSTALLED = status(
|
||||
running: true,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
}
|
||||
);
|
||||
const STOPPED = status(
|
||||
@@ -464,7 +486,9 @@ const STOPPED = status(
|
||||
running: false,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
actions: ['start', 'remove'],
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start', 'install', 'remove'],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1705,6 +1729,8 @@ describe('an installed model', () => {
|
||||
// row's own estimate, so the two figures are never read as a contradiction.
|
||||
expect(texts(body, '.lm-aside')).toEqual([
|
||||
'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.',
|
||||
// Under the list of models: what the downloads take on disk.
|
||||
'Downloaded models use 23.1 GB on this Mac.',
|
||||
]);
|
||||
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
// One main action.
|
||||
@@ -2369,3 +2395,536 @@ describe('an installed model whose runtime did not start', () => {
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The models on this Mac ----------
|
||||
|
||||
const GPT_ROW: LocalModelLibraryRow = {
|
||||
id: 'gpt-oss:20b',
|
||||
label: 'GPT-OSS 20B',
|
||||
context: 65536,
|
||||
sizeBytes: 13793441427,
|
||||
sizeGb: '13.8',
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use', 'delete'],
|
||||
};
|
||||
const STORE_TWO = { bytes: 36899240383, gb: '36.9' };
|
||||
|
||||
/** Two models on this Mac, the Qwen one in use and running. */
|
||||
const TWO = status(
|
||||
{ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' },
|
||||
{
|
||||
...INSTALLED.install,
|
||||
library: [QWEN_ROW, GPT_ROW],
|
||||
store: STORE_TWO,
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
||||
}
|
||||
);
|
||||
|
||||
/** The buttons of the rows of the list (not the buttons of the screen), by their label. */
|
||||
const rowButtons = (page: Mounted) => texts(page.body, '.lm-lib-row .lm-lib-actions:not(.lm-lib-actions-away) button');
|
||||
|
||||
function pressIn(page: Mounted, selector: string, label: string) {
|
||||
const found = [...page.frame.querySelectorAll<HTMLButtonElement>(`${selector} button`)].find(
|
||||
(el) => el.textContent === label
|
||||
);
|
||||
expect(found, `no button "${label}" in ${selector}`).toBeTruthy();
|
||||
found!.click();
|
||||
}
|
||||
|
||||
describe('the models on this Mac', () => {
|
||||
it('lists the one installed model with its context, its state and its size on disk, and offers another', async () => {
|
||||
const page = mount();
|
||||
await opened(page, INSTALLED);
|
||||
const { body } = page;
|
||||
expect(texts(body, '.lm-lib-caption-label')).toEqual(['Models on this Mac']);
|
||||
expect(texts(body, '.lm-lib-caption-size')).toEqual(['On disk']);
|
||||
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens']);
|
||||
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use']);
|
||||
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB']);
|
||||
// The model in use wears the selected look, and nothing can be done ON it here.
|
||||
expect(body.querySelector('.lm-lib-row')!.classList.contains('lm-lib-row-on')).toBe(true);
|
||||
expect(rowButtons(page)).toEqual([]);
|
||||
expect(texts(body, '.lm-lib-foot button')).toEqual(['Add a model']);
|
||||
// A real list for assistive technology, named by its caption.
|
||||
const list = body.querySelector('.lm-lib-list')!;
|
||||
expect(list.getAttribute('role')).toBe('list');
|
||||
expect(list.getAttribute('aria-labelledby')).toBe('localModelLibraryLabel');
|
||||
expect(body.querySelectorAll('[role="listitem"]')).toHaveLength(1);
|
||||
// The screen scrolls from its top: it is no short screen any more.
|
||||
expect(body.dataset.long).toBe('true');
|
||||
// The buttons of the screen are what they were.
|
||||
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
});
|
||||
|
||||
it('two models: the one in use first, the other with Use and Delete, and what both take on disk', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
const { body } = page;
|
||||
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens', '65,536 tokens']);
|
||||
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']);
|
||||
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB', '13.8 GB']);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
// A short label on screen, the model's name for a screen reader.
|
||||
const [use, remove] = [...body.querySelectorAll('.lm-lib-row .lm-lib-actions button')];
|
||||
expect(use.getAttribute('aria-label')).toBe('Use GPT-OSS 20B');
|
||||
expect(remove.getAttribute('aria-label')).toBe('Delete GPT-OSS 20B');
|
||||
// Quiet buttons: the screen keeps its one main action, and nothing here is red yet.
|
||||
expect(body.querySelectorAll('.lm-library .wt-btn-primary, .lm-library .wt-btn-danger')).toHaveLength(0);
|
||||
expect(texts(body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 36.9 GB on this Mac.']);
|
||||
});
|
||||
|
||||
it('a row has a button only for an action the server lists for THAT row', async () => {
|
||||
const page = mount();
|
||||
const rows: LocalModelLibraryRow[] = [
|
||||
QWEN_ROW,
|
||||
{ ...GPT_ROW, actions: ['use'] },
|
||||
{ ...GPT_ROW, id: 'a:1b', label: 'A', status: 'changed', actions: ['delete'] },
|
||||
{ ...GPT_ROW, id: 'b:1b', label: 'B', status: 'missing', actions: ['delete'] },
|
||||
{ ...GPT_ROW, id: 'c:1b', label: 'C', context: undefined, verified: false, status: 'unverified', actions: [] },
|
||||
{ id: 'd:1b', label: 'D', active: false, verified: false, status: 'unverified', actions: ['delete'] },
|
||||
];
|
||||
await opened(page, status({}, { ...TWO.install, library: rows }));
|
||||
const perRow = [...page.body.querySelectorAll('.lm-lib-row')].map((row) => [
|
||||
row.querySelector('.lm-lib-label')!.textContent,
|
||||
row.querySelector('.lm-tag')!.textContent,
|
||||
texts(row, '.lm-lib-actions button').join('+'),
|
||||
]);
|
||||
expect(perRow).toEqual([
|
||||
['Qwen3.6 35B-A3B', 'In use', ''],
|
||||
['GPT-OSS 20B', 'Ready', 'Use'],
|
||||
['A', 'Changed', 'Delete'],
|
||||
['B', 'Missing', 'Delete'],
|
||||
['C', 'Not checked', ''],
|
||||
['D', 'Not checked', 'Delete'],
|
||||
]);
|
||||
// A row without a context or a size prints neither: no "undefined", no empty unit.
|
||||
const last = [...page.body.querySelectorAll('.lm-lib-row')].at(-1)!;
|
||||
expect(last.querySelector('.lm-lib-meta')).toBeNull();
|
||||
expect(last.querySelector('.lm-lib-size')!.textContent).toBe('');
|
||||
expect(page.body.textContent).not.toContain('undefined');
|
||||
// Each state has a colour of its own.
|
||||
expect([...page.body.querySelectorAll('.lm-lib-tag .lm-tag')].map((tag) => tag.className)).toEqual([
|
||||
'lm-tag lm-tag-inuse',
|
||||
'lm-tag lm-tag-ready',
|
||||
'lm-tag lm-tag-changed',
|
||||
'lm-tag lm-tag-missing',
|
||||
'lm-tag lm-tag-unverified',
|
||||
'lm-tag lm-tag-unverified',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Use switches at once: no confirm screen, and the screen says a switch is running', async () => {
|
||||
const page = mount();
|
||||
const switching: LocalModelInstallView = {
|
||||
...TWO.install,
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' },
|
||||
context: 65536,
|
||||
library: [
|
||||
{ ...QWEN_ROW, active: false },
|
||||
{ ...GPT_ROW, actions: [] },
|
||||
],
|
||||
actions: ['cancel'],
|
||||
};
|
||||
const { app } = await opened(page, TWO, () => ok(switching));
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'gpt-oss:20b' }]]);
|
||||
expect(page.body.dataset.screen).toBe('progress');
|
||||
expect(texts(page.body, '.lm-title')).toEqual(['Switching to GPT-OSS 20B']);
|
||||
// Nothing is downloaded: no download bar, and the two steps of a switch.
|
||||
expect(page.body.querySelector('.lm-meter')).toBeNull();
|
||||
expect(texts(page.body, '.lm-step-label')).toEqual(['Loading and checking', 'Connecting']);
|
||||
expect(texts(page.body, '.lm-aside')).toEqual([
|
||||
'You can close this window. The switch continues while Codeman is running.',
|
||||
]);
|
||||
expect(buttons(page.body)).toEqual(['Cancel']);
|
||||
});
|
||||
|
||||
it('a switch that is refused keeps the screen and prints the server sentence with the session names', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, TWO, () =>
|
||||
conflict({
|
||||
reason: 'sessions-open',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
|
||||
sessions: ['w1-app', 'w2-api'],
|
||||
})
|
||||
);
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(texts(page.body, '.lm-notice-text')).toEqual([
|
||||
'Sessions are still using this model: w1-app, w2-api. Close them first.',
|
||||
]);
|
||||
expect(verbs(app)).toHaveLength(1);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
});
|
||||
|
||||
it('Delete asks once, in the row, and sends nothing until the red button is pressed', async () => {
|
||||
const page = mount();
|
||||
const after: LocalModelInstallView = {
|
||||
...TWO.install,
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
};
|
||||
const { app } = await opened(page, TWO, () => ok(after));
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([]);
|
||||
const ask = page.body.querySelector('.lm-lib-ask')!;
|
||||
expect(texts(ask, '.lm-lead')).toEqual(['Delete GPT-OSS 20B from this Mac?']);
|
||||
expect(texts(ask, '.lm-aside')).toEqual(['This frees 13.8 GB.']);
|
||||
expect(texts(ask, 'button')).toEqual(['Cancel', 'Delete']);
|
||||
// Red only here, in its own question.
|
||||
expect(page.frame.querySelectorAll('.wt-btn-danger')).toHaveLength(1);
|
||||
expect(ask.querySelector('.wt-btn-danger')!.textContent).toBe('Delete');
|
||||
// The question sits right under the row it is about. That row's own buttons keep their
|
||||
// room (the sizes must not jump) and are out of reach: unseen, disabled, hidden from a reader.
|
||||
const asked = ask.previousElementSibling!;
|
||||
expect(asked.querySelector('.lm-lib-label')!.textContent).toBe('GPT-OSS 20B');
|
||||
expect(rowButtons(page)).toEqual([]);
|
||||
const away = asked.querySelector('.lm-lib-actions')!;
|
||||
expect(away.classList.contains('lm-lib-actions-away')).toBe(true);
|
||||
expect(away.getAttribute('aria-hidden')).toBe('true');
|
||||
expect([...away.querySelectorAll('button')].every((button) => button.disabled)).toBe(true);
|
||||
// The focus is on the safe answer.
|
||||
expect((page.document.activeElement as HTMLElement).textContent).toBe('Cancel');
|
||||
|
||||
pressIn(page, '.lm-lib-ask', 'Cancel');
|
||||
await settle();
|
||||
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
expect(verbs(app)).toEqual([]);
|
||||
// Back on the button that asked.
|
||||
expect((page.document.activeElement as HTMLElement).dataset.lmKey).toBe('delete:gpt-oss:20b');
|
||||
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
pressIn(page, '.lm-lib-ask', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['DELETE', '/api/local-model/models', { modelId: 'gpt-oss:20b' }]]);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 23.1 GB on this Mac.']);
|
||||
});
|
||||
|
||||
it('a delete the server refuses leaves the row and prints why', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO, () =>
|
||||
conflict({
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'GPT-OSS 20B' } },
|
||||
})
|
||||
);
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
pressIn(page, '.lm-lib-ask', 'Delete');
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-notice-text')).toEqual([
|
||||
'GPT-OSS 20B is the model in use. Switch to another model first, or remove the local model.',
|
||||
]);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
});
|
||||
|
||||
it('the Delete question of a row and the Remove question are never open together', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(page.body.querySelector('.lm-lib-ask')).not.toBeNull();
|
||||
press(page.body, 'Remove');
|
||||
await settle();
|
||||
expect(page.frame.querySelector('.lm-remove')).not.toBeNull();
|
||||
expect(page.frame.querySelector('.lm-lib-ask')).toBeNull();
|
||||
});
|
||||
|
||||
it('with the runtime stopped a row offers no delete, and one line says how to get it', async () => {
|
||||
const page = mount();
|
||||
const stopped = status(
|
||||
{},
|
||||
{
|
||||
...STOPPED.install,
|
||||
library: [QWEN_ROW, { ...GPT_ROW, actions: ['use'] }],
|
||||
store: STORE_TWO,
|
||||
actions: ['start', 'install', 'use', 'remove'],
|
||||
}
|
||||
);
|
||||
await opened(page, stopped);
|
||||
expect(rowButtons(page)).toEqual(['Use']);
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual([
|
||||
'Downloaded models use 36.9 GB on this Mac.',
|
||||
'Press Start to delete a single model.',
|
||||
]);
|
||||
// With one model there is no single model to delete, so the line is not there.
|
||||
const one = mount();
|
||||
await opened(one, STOPPED);
|
||||
expect(one.body.textContent).not.toContain('Press Start to delete');
|
||||
});
|
||||
|
||||
it('the screen that ends an install says one thing: no list, one button', async () => {
|
||||
const page = mount();
|
||||
const { app, serve } = await opened(page, INSTALLING);
|
||||
serve(TWO);
|
||||
await app.loadLocalModel({ force: true });
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(page.body.querySelector('.lm-library')).toBeNull();
|
||||
expect(page.body.dataset.long).toBe('false');
|
||||
expect(buttons(page.body)).toEqual(['Start a session']);
|
||||
});
|
||||
|
||||
it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, TWO);
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('choose');
|
||||
expect(page.body.querySelectorAll('[role="radio"]').length).toBeGreaterThan(1);
|
||||
expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']);
|
||||
press(page.body, 'Back');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(verbs(app)).toEqual([]);
|
||||
// No button without the server offering an install (no row fits, or it is not listed).
|
||||
const none = mount();
|
||||
await opened(none, status({}, { ...TWO.install, actions: ['start-session', 'stop', 'remove'] }));
|
||||
expect(texts(none.body, '.lm-lib-foot button')).toEqual([]);
|
||||
});
|
||||
|
||||
it('in the chooser a model that is on this Mac says so, and is switched to without a download', async () => {
|
||||
const page = mount();
|
||||
const answer = status(
|
||||
{
|
||||
pick: { ...PICK, library: 'active' },
|
||||
alternatives: [{ ...ALT_SLOW, library: 'ready' }, ALT_SMALL],
|
||||
},
|
||||
TWO.install
|
||||
);
|
||||
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
const rows = [...page.body.querySelectorAll('.lm-model')];
|
||||
const tagsOf = (row: Element) => texts(row, '.lm-tag');
|
||||
// The model in use: its own tag beside the server's, and nothing to do with it here.
|
||||
expect(tagsOf(rows[0])).toContain('In use');
|
||||
const primary = () => page.frame.querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
|
||||
expect(primary().textContent).toBe('In use');
|
||||
expect(primary().disabled).toBe(true);
|
||||
|
||||
// A model that is on this Mac and checked: one click switches to it.
|
||||
const ready = rows.find((row) => tagsOf(row).includes('On this Mac'))!;
|
||||
expect(ready).toBeTruthy();
|
||||
(ready as HTMLElement).click();
|
||||
await settle();
|
||||
expect(primary().textContent).toBe('Use this model');
|
||||
expect(primary().disabled).toBe(false);
|
||||
primary().click();
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: ALT_SLOW.modelId }]]);
|
||||
// No confirm screen was shown on the way.
|
||||
expect(page.body.dataset.screen).toBe('progress');
|
||||
});
|
||||
|
||||
it('in the chooser a model that is not on this Mac is installed the usual way, with its sizes confirmed', async () => {
|
||||
const page = mount();
|
||||
const answer = status({ pick: { ...PICK, library: 'active' }, alternatives: [ALT_SMALL] }, TWO.install);
|
||||
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'model' }));
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
const other = [...page.body.querySelectorAll<HTMLElement>('.lm-model')].find(
|
||||
(row) => row.dataset.modelId === ALT_SMALL.modelId
|
||||
)!;
|
||||
other.click();
|
||||
await settle();
|
||||
press(page.body, 'Install on this Mac');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('confirm');
|
||||
press(page.body, 'Download and install');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([
|
||||
[
|
||||
'POST',
|
||||
'/api/local-model/install',
|
||||
{
|
||||
modelId: ALT_SMALL.modelId,
|
||||
cli: ALT_SMALL.harnesses[0].cli,
|
||||
context: ALT_SMALL.context,
|
||||
acknowledgedBytes: ALT_SMALL.installBytes,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a job that stopped beside a checked model shows the way back to that model', async () => {
|
||||
const back: LocalModelLibraryRow = { ...QWEN_ROW, active: false, actions: ['use', 'delete'] };
|
||||
for (const first of [
|
||||
status(
|
||||
{},
|
||||
{ ...PAUSED.install, library: [back], actions: ['resume', 'install', 'use', 'delete-model', 'remove'] }
|
||||
),
|
||||
status(
|
||||
{},
|
||||
{
|
||||
...failed('no-tool-call', { model: 'GPT-OSS 20B' }).install,
|
||||
library: [back],
|
||||
actions: ['resume', 'install', 'use', 'delete-model', 'remove'],
|
||||
}
|
||||
),
|
||||
]) {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, first, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['Ready']);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
// No "Add a model" here: the job's own buttons offer another model.
|
||||
expect(texts(page.body, '.lm-lib-foot button')).toEqual([]);
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'qwen3.6:35b-a3b' }]]);
|
||||
}
|
||||
});
|
||||
|
||||
it('a first job that stopped has nothing to go back to, and shows no list', async () => {
|
||||
const page = mount();
|
||||
// Its own model is on disk and checked, with nothing offered on it: Resume goes on with it.
|
||||
await opened(page, status({}, { ...PAUSED.install, library: [{ ...QWEN_ROW, active: false }] }));
|
||||
expect(page.body.querySelector('.lm-library')).toBeNull();
|
||||
expect(page.body.dataset.long).toBe('false');
|
||||
const stopped = mount();
|
||||
await opened(stopped, failed('load-failed', { model: 'Qwen3.6 35B-A3B' }));
|
||||
expect(stopped.body.querySelector('.lm-library')).toBeNull();
|
||||
});
|
||||
|
||||
it('a paused switch says so, and waits', async () => {
|
||||
const page = mount();
|
||||
await opened(
|
||||
page,
|
||||
status({}, { ...PAUSED.install, step: 'verify', steps: ['verify', 'connect'], progress: undefined })
|
||||
);
|
||||
expect(texts(page.body, '.lm-title')).toEqual(['The switch to Qwen3.6 35B-A3B is paused']);
|
||||
expect(texts(page.body, '.lm-aside')).toEqual(['The switch waits until you continue.']);
|
||||
expect(page.body.querySelector('.lm-meter')).toBeNull();
|
||||
});
|
||||
|
||||
it('the App Settings row names a switch as a switch', async () => {
|
||||
const page = mount();
|
||||
const job = { ...JOB, steps: ['verify', 'connect'] as LocalModelInstallView['steps'], step: 'verify' as const };
|
||||
for (const [state, text] of [
|
||||
['installing', 'Switching to Qwen3.6 35B-A3B'],
|
||||
['paused', 'The switch to Qwen3.6 35B-A3B is paused'],
|
||||
['failed', 'The switch to Qwen3.6 35B-A3B stopped'],
|
||||
] as const) {
|
||||
const app = page.newApp(ok(status({}, { ...job, state, actions: [] })));
|
||||
await app.loadLocalModel({ force: true });
|
||||
expect(texts(page.row, '.set-row-label')).toEqual([text]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('what an earlier Remove left on disk', () => {
|
||||
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });
|
||||
|
||||
it('the chooser says what the downloads still take, and deletes them after one question', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, LEFT, () => ok(install({ store: { bytes: 0, gb: '0.0' } })));
|
||||
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
|
||||
'Models from an earlier install still use 23.1 GB on this Mac.',
|
||||
]);
|
||||
pressIn(page, '.lm-leftovers', 'Delete them');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([]);
|
||||
expect(texts(page.body, '.lm-leftovers .lm-lead')).toEqual(['Delete the downloaded models from this Mac?']);
|
||||
expect(texts(page.body, '.lm-leftovers .lm-lib-ask .lm-aside')).toEqual(['This frees 23.1 GB.']);
|
||||
expect(page.body.querySelector('.lm-leftovers .wt-btn-danger')!.textContent).toBe('Delete');
|
||||
pressIn(page, '.lm-leftovers', 'Cancel');
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-leftovers button')).toEqual(['Delete them']);
|
||||
pressIn(page, '.lm-leftovers', 'Delete them');
|
||||
await settle();
|
||||
pressIn(page, '.lm-leftovers', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
|
||||
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
|
||||
// Still the chooser: installing is what this screen is for.
|
||||
expect(page.body.dataset.screen).toBe('choose');
|
||||
expect(buttons(page.body)).toEqual(['Install on this Mac']);
|
||||
});
|
||||
|
||||
it('is not there without something on disk, or without the server offering the removal', async () => {
|
||||
for (const first of [
|
||||
IDLE,
|
||||
status({}, { store: { bytes: 0, gb: '0.0' }, actions: ['install', 'remove'] }),
|
||||
status({}, { store: STORE_ONE, actions: ['install'] }),
|
||||
]) {
|
||||
const page = mount();
|
||||
await opened(page, first);
|
||||
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows on a Mac no model fits too: the space can still be freed', async () => {
|
||||
const page = mount();
|
||||
const answer = none('memory');
|
||||
await opened(page, { ...answer, install: install({ store: STORE_ONE, actions: ['remove'] }) });
|
||||
expect(page.body.dataset.screen).toBe('none');
|
||||
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
|
||||
'Models from an earlier install still use 23.1 GB on this Mac.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the models on this Mac: text of someone else, another language, an unchanged answer', () => {
|
||||
it('a model name is text, in the row, in its question and in the labels of its buttons', async () => {
|
||||
const HOSTILE = '<img src=x onerror="window.__pwned=1">';
|
||||
const page = mount();
|
||||
const rows: LocalModelLibraryRow[] = [QWEN_ROW, { ...GPT_ROW, id: `evil:1b`, label: `Model ${HOSTILE}` }];
|
||||
await opened(page, status({}, { ...TWO.install, library: rows, folder: `/tmp/${HOSTILE}` }));
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0);
|
||||
expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined();
|
||||
expect(texts(page.body, '.lm-lib-ask .lm-lead')).toEqual([`Delete Model ${HOSTILE} from this Mac?`]);
|
||||
});
|
||||
|
||||
it('is translated when the language changes, the names staying as sent', async () => {
|
||||
const page = mount({ i18n: true });
|
||||
page.win.CodemanI18n.start();
|
||||
await opened(page, TWO);
|
||||
page.win.CodemanI18n.configure({ language: 'zh-CN' });
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-lib-caption-label')).toEqual(['此 Mac 上的模型']);
|
||||
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['使用中', '就绪']);
|
||||
expect(rowButtons(page)).toEqual(['使用', '删除']);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['已下载的模型在此 Mac 上占用 36.9 GB。']);
|
||||
expect(texts(page.body, '.lm-lib-foot button')).toEqual(['添加模型']);
|
||||
});
|
||||
|
||||
it('a poll that changes nothing leaves the rows and their buttons in place', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
const before = page.body.querySelector('.lm-lib-row .lm-lib-actions button');
|
||||
await page.tick();
|
||||
await page.tick();
|
||||
expect(page.body.querySelector('.lm-lib-row .lm-lib-actions button')).toBe(before);
|
||||
});
|
||||
|
||||
it('the focus stays on the row button it was on when the list is repainted', async () => {
|
||||
const page = mount();
|
||||
const { app, serve } = await opened(page, TWO);
|
||||
const remove = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-lib-row .lm-lib-actions button')][1];
|
||||
remove.focus();
|
||||
serve(status({}, { ...TWO.install, store: { bytes: 36899240384, gb: '36.9' }, port: 11436 }));
|
||||
await app.loadLocalModel({ force: true });
|
||||
const active = page.document.activeElement as HTMLElement;
|
||||
expect(active.textContent).toBe('Delete');
|
||||
expect(active.dataset.lmKey).toBe('delete:gpt-oss:20b');
|
||||
expect(active).not.toBe(remove);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,7 @@ const IDLE_VIEW: LocalModelInstallView = {
|
||||
developerTools: 'missing',
|
||||
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
};
|
||||
|
||||
@@ -182,6 +183,8 @@ function fakeService(): ServiceMock {
|
||||
launchRefusalMessage: vi.fn(async () => undefined),
|
||||
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
|
||||
install: vi.fn(async () => OK_RESULT),
|
||||
use: vi.fn(async () => OK_RESULT),
|
||||
deleteLibraryModel: vi.fn(async () => OK_RESULT),
|
||||
cancel: vi.fn(async () => OK_RESULT),
|
||||
resume: vi.fn(async () => OK_RESULT),
|
||||
runtime: vi.fn(async () => OK_RESULT),
|
||||
@@ -555,6 +558,13 @@ describe('the install verbs', () => {
|
||||
/** 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: 'use', method: 'POST', url: '/api/local-model/use', payload: { modelId: 'qwen3.8:27b' } },
|
||||
{
|
||||
name: 'deleteLibraryModel',
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'qwen3.8:27b' },
|
||||
},
|
||||
{ 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' } },
|
||||
@@ -654,7 +664,18 @@ describe('the install verbs', () => {
|
||||
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' });
|
||||
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }, []);
|
||||
});
|
||||
|
||||
it('hands the service the names of the live sessions on the model: another model would take the runtime from them', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, undefined, [
|
||||
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
||||
{ name: 'w2-app' },
|
||||
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
||||
]);
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
||||
expect(f.service.install.mock.calls[0][2]).toEqual(['w1-app']);
|
||||
});
|
||||
|
||||
it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => {
|
||||
@@ -925,6 +946,177 @@ describe('the install verbs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the library verbs', () => {
|
||||
const SESSIONS: SessionLike[] = [
|
||||
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
||||
{ name: 'w2-app' },
|
||||
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
||||
];
|
||||
|
||||
describe('POST /api/local-model/use', () => {
|
||||
it('hands the service the model, who asked and the live sessions on the model', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'gpt-oss:20b' } });
|
||||
expect(f.service.use).toHaveBeenCalledWith({ modelId: 'gpt-oss:20b' }, { username: 'alice', ip: '127.0.0.1' }, [
|
||||
'w1-app',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a model that is not one of the checked ones is a 400 with the sentence, as text and as data', async () => {
|
||||
const f = fakes();
|
||||
f.service.use.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'x:1b' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'This model is not one of the checked models on this Mac.',
|
||||
errorCode: 'INVALID_INPUT',
|
||||
data: { message: { text: 'This model is not one of the checked models on this Mac.' } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no body', undefined],
|
||||
['no model', {}],
|
||||
['a path', { modelId: '../../etc/passwd' }],
|
||||
['another registry', { modelId: 'hf.co/someone/model:Q4' }],
|
||||
['a member it does not know', { modelId: 'gpt-oss:20b', context: 65536 }],
|
||||
])('refuses %s before the service is asked', async (_name, payload) => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', ...(payload ? { payload } : {}) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(f.service.use).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/local-model/models', () => {
|
||||
it('hands the service the model name and who asked', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
||||
await app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId: 'gpt-oss:20b' } });
|
||||
expect(f.service.deleteLibraryModel).toHaveBeenCalledWith(
|
||||
{ modelId: 'gpt-oss:20b' },
|
||||
{ username: 'alice', ip: '127.0.0.1' }
|
||||
);
|
||||
// The whole installation is another route: it was not asked.
|
||||
expect(f.service.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no body', undefined],
|
||||
['a path', { modelId: '/Users/me/.codeman/local-model/models' }],
|
||||
['a path that climbs', { modelId: '../../etc/passwd' }],
|
||||
['a name with a slash', { modelId: 'library/qwen3.8:27b' }],
|
||||
['a name without a tag', { modelId: 'qwen3.8' }],
|
||||
['a path member', { modelId: 'gpt-oss:20b', path: '/' }],
|
||||
])('takes a model name and nothing else: refuses %s', async (_name, payload) => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
...(payload ? { payload } : {}),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(f.service.deleteLibraryModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the model in use: 409 with the reason and the sentence', async () => {
|
||||
const f = fakes();
|
||||
f.service.deleteLibraryModel.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.8 27B' } },
|
||||
},
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'qwen3.8:27b' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'Qwen3.8 27B is the model in use. Switch to another model first, or remove the local model.',
|
||||
errorCode: 'CONFLICT',
|
||||
data: {
|
||||
reason: 'model-active',
|
||||
message: {
|
||||
text: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
||||
vars: { model: 'Qwen3.8 27B' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('a runtime that did not delete it: 500 with the sentence, as text and as data', async () => {
|
||||
const f = fakes();
|
||||
f.service.deleteLibraryModel.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'gpt-oss:20b' },
|
||||
});
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'GPT-OSS 20B could not be deleted from this Mac.',
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
data: { message: { text: '{model} could not be deleted from this Mac.', vars: { model: 'GPT-OSS 20B' } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/local-model marks the rows that are already on this Mac', () => {
|
||||
it('the model in use and a model a switch can take, at the context they were checked with', async () => {
|
||||
const second = { ...OK.pick!, modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B', context: 65536 };
|
||||
const third = { ...OK.pick!, modelId: 'mimo-v2.6:9b-q4_k_m', modelLabel: 'MiMo V2.6 9B' };
|
||||
const f = fakes(MAC, { ...OK, alternatives: [second, third] });
|
||||
const row = (id: string, context: number, active: boolean) => ({
|
||||
id,
|
||||
label: id,
|
||||
context,
|
||||
active,
|
||||
verified: true,
|
||||
status: 'ready' as const,
|
||||
actions: active ? [] : ['use' as const],
|
||||
});
|
||||
f.service.view.mockResolvedValue({
|
||||
...IDLE_VIEW,
|
||||
state: 'installed',
|
||||
library: [row(OK.pick!.modelId, OK.pick!.context, true), row('gpt-oss:20b', 65536, false)],
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
||||
});
|
||||
const data = (await get(f.deps)).json().data;
|
||||
expect(data.recommendation.pick.library).toBe('active');
|
||||
expect(data.recommendation.alternatives[0].library).toBe('ready');
|
||||
expect(data.recommendation.alternatives[1]).not.toHaveProperty('library');
|
||||
// What recommend() answered is not written to.
|
||||
expect(OK.pick).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('with nothing on this Mac no row carries the mark', async () => {
|
||||
const data = (await get(fakes().deps)).json().data;
|
||||
expect(data.recommendation.pick).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the routes over the real install service', () => {
|
||||
const STATE = () => dataPath('local-model.json');
|
||||
|
||||
|
||||
Reference in new issue
Block a user