mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge #806: pick the coding agent per session, with Pi and DeepSeek Harness, and install a missing one
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
commit
f7fccc5dfc
26 files changed
+2013
-113
No files matched your search
@@ -194,6 +194,24 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
|
||||
});
|
||||
|
||||
it('pi: the model entry carries the endpoint window and a reply share of it, or pi takes the model for a 128k one', () => {
|
||||
const models = (contextLength?: number) => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3.6:35b-a3b', contextLength);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
return JSON.parse(result.files[0].content).providers.custom.models;
|
||||
};
|
||||
// The full window, and 8192 reply tokens at most (measured on pi 1.5.2: the request then
|
||||
// carries max_tokens 8192 instead of its own 16384).
|
||||
expect(models(65536)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 65536, maxTokens: 8192 }]);
|
||||
expect(models(131072.9)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 131072, maxTokens: 8192 }]);
|
||||
// A small window keeps three quarters for the prompt.
|
||||
expect(models(16384)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 16384, maxTokens: 4096 }]);
|
||||
// Unknown stays unknown: no limit is invented.
|
||||
for (const contextLength of [undefined, NaN, 0, -1, Infinity]) {
|
||||
expect(models(contextLength), String(contextLength)).toEqual([{ id: 'qwen3.6:35b-a3b' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
@@ -239,9 +257,9 @@ describe('buildCustomModelInjection', () => {
|
||||
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
|
||||
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
|
||||
// api.deepseek.com on an in-session /model switch back to the official route.
|
||||
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
|
||||
'CODEMAN_CUSTOM_MODEL_API_KEY'
|
||||
);
|
||||
expect(
|
||||
patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv
|
||||
).toBe('CODEMAN_CUSTOM_MODEL_API_KEY');
|
||||
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
|
||||
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
|
||||
});
|
||||
|
||||
@@ -322,12 +322,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('holds no more models than one window lists: the pick and MAX_ALTERNATIVES others', () => {
|
||||
@@ -572,7 +588,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. */
|
||||
@@ -602,9 +610,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,
|
||||
@@ -1718,9 +1729,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)) {
|
||||
|
||||
@@ -510,6 +510,71 @@ describe('the local model window (real Chromium)', () => {
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('the coding agents: chips that wrap inside the window at every width, the install command included', async () => {
|
||||
const agents = [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] },
|
||||
{
|
||||
cli: 'claude',
|
||||
cliLabel: 'Claude Code',
|
||||
default: false,
|
||||
status: 'ready',
|
||||
slowFirstTurn: true,
|
||||
actions: ['start-session'],
|
||||
},
|
||||
{
|
||||
cli: 'pi',
|
||||
cliLabel: 'Pi',
|
||||
default: false,
|
||||
status: 'installable',
|
||||
installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
actions: ['install'],
|
||||
},
|
||||
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', actions: [] },
|
||||
];
|
||||
const answer = {
|
||||
...INSTALLED,
|
||||
install: {
|
||||
...INSTALLED.install,
|
||||
harnesses: agents,
|
||||
actions: [...INSTALLED.install.actions, 'install-agent'],
|
||||
},
|
||||
};
|
||||
for (const [width, height] of [
|
||||
[1440, 900],
|
||||
[390, 844],
|
||||
[320, 640],
|
||||
]) {
|
||||
await load(width, height, answer);
|
||||
await open();
|
||||
// The agent that is not on this Mac: its command is the longest line of the block.
|
||||
await p.locator('.lm-agent', { hasText: 'Pi' }).click();
|
||||
const seen = await p.evaluate(() => {
|
||||
const frame = document.querySelector('.local-model-window')!.getBoundingClientRect();
|
||||
const body = document.getElementById('localModelWindowBody')!;
|
||||
const outside = [...document.querySelectorAll('.lm-agent, .lm-agents .lm-command, .lm-buttons button')]
|
||||
.map((el) => el.getBoundingClientRect())
|
||||
.filter((box) => box.width > 0 && (box.left < frame.left - 0.5 || box.right > frame.right + 0.5)).length;
|
||||
const chips = [...document.querySelectorAll<HTMLElement>('.lm-agent')];
|
||||
return {
|
||||
chips: chips.length,
|
||||
outside,
|
||||
sideways: body.scrollWidth - body.clientWidth,
|
||||
checked: document.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')!.textContent,
|
||||
tabStops: chips.filter((chip) => chip.tabIndex === 0).length,
|
||||
main: document.querySelector('.lm-buttons .wt-btn-primary')!.textContent,
|
||||
};
|
||||
});
|
||||
expect(seen, `${width}px`).toEqual({
|
||||
chips: 4,
|
||||
outside: 0,
|
||||
sideways: 0,
|
||||
checked: 'Pi',
|
||||
tabStops: 1,
|
||||
main: 'Install Pi',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('paints above App Settings when it is opened from there', async () => {
|
||||
await load(1440);
|
||||
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
|
||||
|
||||
+376
-1
@@ -51,6 +51,7 @@ import type {
|
||||
HardwareFacts,
|
||||
LocalModelConflict,
|
||||
LocalModelErrorCode,
|
||||
LocalModelHarnessRow,
|
||||
LocalModelInstallView,
|
||||
LocalModelLibraryRow,
|
||||
LocalModelMessage,
|
||||
@@ -77,7 +78,7 @@ interface App {
|
||||
openLocalModelWindow(): void;
|
||||
closeLocalModelWindow(): void;
|
||||
isLocalModelWindowOpen(): boolean;
|
||||
startLocalModelSession(): Promise<void>;
|
||||
startLocalModelSession(cli?: string): Promise<void>;
|
||||
loadAppSettingsFromServer?: () => Promise<void>;
|
||||
loadCliListForSettings?: () => Promise<void>;
|
||||
selectCustomModelEntry?: (cli: string, endpointId: string) => Promise<void>;
|
||||
@@ -389,6 +390,7 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
|
||||
runtime: RUNTIME_MISSING,
|
||||
folder: FOLDER,
|
||||
library: [],
|
||||
harnesses: [],
|
||||
actions: ['install'],
|
||||
...over,
|
||||
};
|
||||
@@ -2827,6 +2829,379 @@ describe('the models on this Mac', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The coding agents of an installed model ----------
|
||||
|
||||
/** The agents the server lists for the installed model: two that can be started, one to install, one that needs a setup. */
|
||||
const AGENTS: LocalModelHarnessRow[] = [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
|
||||
{
|
||||
cli: 'claude',
|
||||
cliLabel: 'Claude Code',
|
||||
default: false,
|
||||
status: 'ready',
|
||||
slowFirstTurn: true,
|
||||
actions: ['start-session'],
|
||||
},
|
||||
{
|
||||
cli: 'pi',
|
||||
cliLabel: 'Pi',
|
||||
default: false,
|
||||
status: 'installable',
|
||||
installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
actions: ['install'],
|
||||
},
|
||||
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] },
|
||||
];
|
||||
|
||||
const withAgents = (rows: LocalModelHarnessRow[], over: Partial<LocalModelInstallView> = {}) =>
|
||||
status(
|
||||
{},
|
||||
{ ...INSTALLED.install, harnesses: rows, actions: [...INSTALLED.install.actions, 'install-agent'], ...over }
|
||||
);
|
||||
|
||||
const WITH_AGENTS = withAgents(AGENTS);
|
||||
|
||||
describe('the coding agents of an installed model', () => {
|
||||
const chips = (page: Mounted) => texts(page.body, '.lm-agent').map((text) => text.trim());
|
||||
const chosen = (page: Mounted) =>
|
||||
page.body.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')?.textContent ?? null;
|
||||
const pick = (page: Mounted, label: string) => {
|
||||
const chip = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-agent')].find(
|
||||
(el) => el.querySelector('.lm-agent-name')!.textContent === label
|
||||
);
|
||||
expect(chip, `no agent "${label}"`).toBeTruthy();
|
||||
chip!.click();
|
||||
};
|
||||
const launcher = (app: App) => {
|
||||
app.loadAppSettingsFromServer = vi.fn(async () => undefined);
|
||||
app.loadCliListForSettings = vi.fn(async () => undefined);
|
||||
app.selectCustomModelEntry = vi.fn(async () => undefined);
|
||||
return app.selectCustomModelEntry;
|
||||
};
|
||||
|
||||
it('is one radio group: a chip per agent in the server order, the default selected, a tag on what is not there', async () => {
|
||||
const page = mount();
|
||||
await opened(page, WITH_AGENTS);
|
||||
const group = page.body.querySelector('.lm-agent-list')!;
|
||||
expect(group.getAttribute('role')).toBe('radiogroup');
|
||||
expect(page.document.getElementById(group.getAttribute('aria-labelledby')!)!.textContent).toBe(
|
||||
'Start a session with'
|
||||
);
|
||||
expect(texts(page.body, '.lm-agent-name')).toEqual(['OpenCode', 'Claude Code', 'Pi', 'DeepSeek']);
|
||||
expect(chips(page)).toEqual(['OpenCode', 'Claude Code', 'PiNot installed', 'DeepSeekNeeds setup']);
|
||||
expect(chosen(page)).toBe('OpenCode');
|
||||
// One tab stop for the group.
|
||||
expect([...page.body.querySelectorAll<HTMLElement>('.lm-agent')].map((el) => el.tabIndex)).toEqual([0, -1, -1, -1]);
|
||||
// The chips say which agent, so the facts line no longer does.
|
||||
expect(page.body.textContent).not.toContain('Runs with');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
expect(frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts the session with the selected agent, and this device remembers the agent it started', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, WITH_AGENTS);
|
||||
const launch = launcher(app);
|
||||
pick(page, 'Claude Code');
|
||||
expect(chosen(page)).toBe('Claude Code');
|
||||
// What the catalogue says about its first answer.
|
||||
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual(['The first answer can take minutes.']);
|
||||
expect(verbs(app)).toEqual([]);
|
||||
// Looking at a chip is not a choice yet.
|
||||
expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBeNull();
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
expect(launch).toHaveBeenCalledWith('claude', 'local-ollama');
|
||||
|
||||
// The window opened again later: the agent last started stands, not the default.
|
||||
expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBe('claude');
|
||||
const again = page.newApp(ok(WITH_AGENTS));
|
||||
await again.loadLocalModel();
|
||||
again.openLocalModelWindow();
|
||||
await settle();
|
||||
expect(chosen(page)).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('a chip that was only looked at never hides Start a session when the window is opened again', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, WITH_AGENTS);
|
||||
pick(page, 'DeepSeek');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop']);
|
||||
app.closeLocalModelWindow();
|
||||
app.openLocalModelWindow();
|
||||
await settle();
|
||||
// Back on the default agent, with the main action in reach.
|
||||
expect(chosen(page)).toBe('OpenCode');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
|
||||
// And an agent that was started once and has since been uninstalled does not win either.
|
||||
page.dom.window.localStorage.setItem('codeman-local-model-agent', 'pi');
|
||||
app.closeLocalModelWindow();
|
||||
app.openLocalModelWindow();
|
||||
await settle();
|
||||
expect(chosen(page)).toBe('OpenCode');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
});
|
||||
|
||||
it('a remembered agent the server no longer lists falls back to the default', async () => {
|
||||
const page = mount();
|
||||
page.dom.window.localStorage.setItem('codeman-local-model-agent', 'gone');
|
||||
const { app } = await opened(page, WITH_AGENTS);
|
||||
expect(chosen(page)).toBe('OpenCode');
|
||||
const launch = launcher(app);
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
|
||||
});
|
||||
|
||||
it('an agent that is not on this Mac: its literal command, and the main button installs it', async () => {
|
||||
const page = mount();
|
||||
const installing = AGENTS.map((row) =>
|
||||
row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row
|
||||
);
|
||||
const { app, serve } = await opened(page, WITH_AGENTS, () =>
|
||||
ok(withAgents(installing, { actions: INSTALLED.install.actions }).install)
|
||||
);
|
||||
pick(page, 'Pi');
|
||||
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
|
||||
'Pi is not on this Mac yet. Codeman installs it with this command:',
|
||||
]);
|
||||
// The command the click consents to, as text, exactly as the server sent it.
|
||||
expect(texts(page.body, '.lm-agent-about .lm-command')).toEqual([
|
||||
'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
]);
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
|
||||
expect(frameOf(page.body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Install Pi');
|
||||
|
||||
press(page.body, 'Install Pi');
|
||||
await settle();
|
||||
// The id goes out as data; the command is the server's own and is never sent.
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
|
||||
expect(chips(page)).toContain('PiInstalling');
|
||||
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
|
||||
'Installing Pi. You can start a session with another agent meanwhile.',
|
||||
]);
|
||||
const main = frameOf(page.body).querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
|
||||
expect([main.textContent, main.disabled]).toEqual(['Installing Pi', true]);
|
||||
// The model is as it was: still installed, still running.
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(texts(page.body, '.lm-state-text')).toEqual(['Running']);
|
||||
|
||||
// The installer landed: the next poll says so, and the same chip now starts a session.
|
||||
serve(
|
||||
withAgents(
|
||||
AGENTS.map((row) =>
|
||||
row.cli === 'pi'
|
||||
? {
|
||||
cli: 'pi',
|
||||
cliLabel: 'Pi',
|
||||
default: false,
|
||||
status: 'ready' as const,
|
||||
actions: ['start-session' as const],
|
||||
}
|
||||
: row
|
||||
)
|
||||
)
|
||||
);
|
||||
await page.tick();
|
||||
expect(chips(page)).toContain('Pi');
|
||||
expect(chosen(page)).toBe('Pi');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
const launch = launcher(app);
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
expect(launch).toHaveBeenCalledWith('pi', 'local-ollama');
|
||||
});
|
||||
|
||||
it('while an agent installs, a session with another agent can be started', async () => {
|
||||
const page = mount();
|
||||
const rows = AGENTS.map((row) => (row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row));
|
||||
const { app } = await opened(page, withAgents(rows, { actions: INSTALLED.install.actions }));
|
||||
expect(chosen(page)).toBe('OpenCode');
|
||||
const launch = launcher(app);
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
|
||||
});
|
||||
|
||||
it('a failed install: the server sentence on the chip that failed, the installer words folded away, and try again', async () => {
|
||||
const page = mount();
|
||||
const rows = AGENTS.map((row) =>
|
||||
row.cli === 'pi'
|
||||
? {
|
||||
...row,
|
||||
error: {
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } },
|
||||
detail: 'npm ERR! <b>network</b> request failed',
|
||||
},
|
||||
}
|
||||
: row
|
||||
);
|
||||
await opened(page, withAgents(rows));
|
||||
// Not shown under another agent.
|
||||
expect(page.body.querySelector('.lm-agent-about .lm-notice')).toBeNull();
|
||||
pick(page, 'Pi');
|
||||
expect(texts(page.body, '.lm-agent-about .lm-notice-text')).toEqual(['Pi could not be installed.']);
|
||||
const detail = page.body.querySelector('.lm-agent-about .lm-detail-text')!;
|
||||
// Someone else's text: shown as text, never as markup.
|
||||
expect(detail.textContent).toBe('npm ERR! <b>network</b> request failed');
|
||||
expect(detail.querySelector('b')).toBeNull();
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
|
||||
// The model itself has no error: the headline still says ready.
|
||||
expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']);
|
||||
});
|
||||
|
||||
it('an agent that needs a setup of its own says where, and has nothing to press', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, WITH_AGENTS);
|
||||
pick(page, 'DeepSeek');
|
||||
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
|
||||
'DeepSeek needs a setup of its own. Set it up from the Run menu, then start a session with it here.',
|
||||
]);
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop']);
|
||||
expect(verbs(app)).toEqual([]);
|
||||
});
|
||||
|
||||
it('with no agent that can be started the main button installs the default one', async () => {
|
||||
const page = mount();
|
||||
const rows: LocalModelHarnessRow[] = [
|
||||
{
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
default: true,
|
||||
status: 'installable',
|
||||
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
|
||||
actions: ['install'],
|
||||
},
|
||||
];
|
||||
await opened(page, withAgents(rows, { actions: ['stop', 'install', 'install-agent', 'remove'] }));
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install OpenCode']);
|
||||
expect(texts(page.body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']);
|
||||
});
|
||||
|
||||
it('a stopped model: Start stays the one main button, and an agent can still be installed', async () => {
|
||||
const page = mount();
|
||||
const rows = AGENTS.map((row) => (row.status === 'ready' ? { ...row, actions: [] } : row));
|
||||
const { app } = await opened(
|
||||
page,
|
||||
withAgents(rows, { running: false, actions: ['start', 'install', 'install-agent', 'remove'] }),
|
||||
() => ok(withAgents(rows, { running: false, actions: ['start', 'install', 'remove'] }).install)
|
||||
);
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Start']);
|
||||
pick(page, 'Pi');
|
||||
expect(buttons(page.body)).toEqual(['Remove', 'Start', 'Install Pi']);
|
||||
const primary = frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary');
|
||||
expect([...primary].map((el) => el.textContent)).toEqual(['Start']);
|
||||
press(page.body, 'Install Pi');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
|
||||
});
|
||||
|
||||
it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => {
|
||||
const page = mount();
|
||||
await opened(page, WITH_AGENTS);
|
||||
page.body.querySelector<HTMLElement>('.lm-agent[aria-checked="true"]')!.focus();
|
||||
const key = (name: string) => {
|
||||
const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true });
|
||||
page.document.activeElement!.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
const walk: Array<[string, string]> = [
|
||||
['ArrowRight', 'Claude Code'],
|
||||
['ArrowDown', 'Pi'],
|
||||
['ArrowLeft', 'Claude Code'],
|
||||
['ArrowUp', 'OpenCode'],
|
||||
// The ends wrap.
|
||||
['ArrowLeft', 'DeepSeek'],
|
||||
['ArrowRight', 'OpenCode'],
|
||||
['End', 'DeepSeek'],
|
||||
['Home', 'OpenCode'],
|
||||
];
|
||||
for (const [name, agent] of walk) {
|
||||
const event = key(name);
|
||||
expect(event.defaultPrevented, name).toBe(true);
|
||||
expect(chosen(page), name).toBe(agent);
|
||||
const active = page.document.activeElement!;
|
||||
expect(active.classList.contains('lm-agent'), name).toBe(true);
|
||||
expect(active.getAttribute('aria-checked'), name).toBe('true');
|
||||
}
|
||||
// Any other key is left alone.
|
||||
expect(key('a').defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('is not on the screen that ends an install, and not while something blocks every session', async () => {
|
||||
const page = mount();
|
||||
const { app, serve } = await opened(page, INSTALLING);
|
||||
serve(WITH_AGENTS);
|
||||
await page.tick();
|
||||
// The install just finished in this window: one thing to do.
|
||||
expect(page.body.querySelector('.lm-agents')).toBeNull();
|
||||
expect(buttons(page.body)).toEqual(['Start a session']);
|
||||
const launch = launcher(app);
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
// The agent that was chosen for the install: the server's default.
|
||||
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
|
||||
|
||||
const blocked = mount();
|
||||
await opened(
|
||||
blocked,
|
||||
withAgents(
|
||||
AGENTS.map((row) => ({ ...row, actions: row.actions.filter((action) => action !== 'start-session') })),
|
||||
{
|
||||
actions: ['stop', 'install', 'remove'],
|
||||
error: {
|
||||
code: 'model-changed',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errModelChanged },
|
||||
next: 'remove',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(blocked.body.querySelector('.lm-agents')).toBeNull();
|
||||
expect(buttons(blocked.body)).not.toContain('Start a session');
|
||||
});
|
||||
|
||||
it('an agent name is text of someone else: shown as text', async () => {
|
||||
const page = mount();
|
||||
const rows = AGENTS.map((row) =>
|
||||
row.cli === 'pi'
|
||||
? { ...row, cliLabel: '<img src=x onerror=alert(1)>', installCommand: '<script>x</script>' }
|
||||
: row
|
||||
);
|
||||
await opened(page, withAgents(rows));
|
||||
pick(page, '<img src=x onerror=alert(1)>');
|
||||
expect(page.body.querySelector('.lm-agents img, .lm-agents script')).toBeNull();
|
||||
expect(texts(page.body, '.lm-command')).toEqual(['<script>x</script>']);
|
||||
expect(buttons(page.body)).toContain('Install <img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('in Chinese: the label, the tags, the line and the button', async () => {
|
||||
const page = mount({ i18n: true });
|
||||
page.win.CodemanI18n.start();
|
||||
page.win.CodemanI18n.configure({ language: 'zh-CN' });
|
||||
await opened(page, WITH_AGENTS);
|
||||
expect(texts(page.body, '.lm-agents-label')).toEqual(['开始会话时使用']);
|
||||
expect(texts(page.body, '.lm-agent .lm-tag')).toEqual(['未安装', '需要设置']);
|
||||
pick(page, 'Pi');
|
||||
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
|
||||
'此 Mac 上尚未安装 Pi。Codeman 会用下面这条命令安装它:',
|
||||
]);
|
||||
expect(buttons(page.body)).toContain('安装 Pi');
|
||||
});
|
||||
|
||||
it('a server that lists no agents (an older one): the one agent of the install, as before', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, INSTALLED);
|
||||
expect(page.body.querySelector('.lm-agents')).toBeNull();
|
||||
expect(page.body.textContent).toContain('Runs with OpenCode.');
|
||||
const launch = launcher(app);
|
||||
press(page.body, 'Start a session');
|
||||
await settle();
|
||||
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what an earlier Remove left on disk', () => {
|
||||
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });
|
||||
|
||||
|
||||
@@ -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