mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
feat(custom-model): launch directly on the endpoint, no restart, for 7 of 8 CLIs
Fixes the visible double-launch reported on Codex: picking a custom-model
Run-menu entry launched natively first, waited for it to settle, then
restarted it in place with the endpoint applied. Necessary for the design at
the time, but visibly a native boot immediately followed by a second one -
worst on a CLI whose TUI fully reinitializes on a restart, confirmed live on
Codex.
POST /api/quick-start gains an optional customModel field
({endpointId, modelId, confirmed?}). When present, the route mints the
session's id itself (crypto.randomUUID()) before constructing it, computes
the same injection the existing POST /api/sessions/:id/custom-model route
computes (including the llama-swap conflict check from the last commit -
same {requiresConfirmation, currentlyLoadedModel, affectedSessions} shape,
no session created until confirmed), and launches the session already
pointed at the endpoint: env vars via the constructor, and the launchModel
override merged onto piConfig/grokConfig/ompConfig using the registry's own
launch.legacyConfigField the same way session.ts's restart path already
does. No restart at all - setCustomModel() afterward is bookkeeping only.
Wired into 7 of 8 launch functions (session-ui.js): openCode, codex, gemini,
pi, grok, deepseek, omp. Claude stays on the original launch-then-restart
path for now: its own --resume-based restart is far less jarring than the
other seven's, and runClaude()'s multi-tab launch plus docker-config-drift
confirm/retry loop make folding it into the one-shot path separate,
higher-risk work than the other seven's each-a-single-simple-launch shape.
Also fixes a pre-existing 'mode === omp' branch flagged by the CLI-id
static guard (test/cli-registry-no-id-branching.test.ts) - the ompConfig
launchModel merge is the same 'legacy <Mode>Config plumbing' category as
the six sibling branches already allowlisted there, just newly literal
where it was previously only inside resolveOmpConfigForCreate's own check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fbee1b2d82
commit
f865f74a0f
@@ -80,6 +80,8 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'omp'":
|
||||
'legacy <Mode>Config plumbing (custom-model launchModel merge onto ompConfig, same selection resolveOmpConfigForCreate already makes internally)',
|
||||
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @fileoverview Frontend tests for the one-shot custom-model launch path added to
|
||||
* session-ui.js (docs/custom-model-endpoints-plan.md): `runCustomModelEntry` dispatches
|
||||
* to `_runCustomModelEntryOneShot` for every custom-model-eligible CLI except claude,
|
||||
* which launches directly on the endpoint (no restart) by folding `customModel` into
|
||||
* the run<Mode>() function's own `/api/quick-start` body via `_pendingCustomModelForLaunch`
|
||||
* and `_quickStartWithCustomModelConfirm`. Fixes the visible native-boot-then-restart the
|
||||
* restart-after-launch path (`_runCustomModelEntryViaRestart`, still used for claude)
|
||||
* showed on every custom-model run — confirmed live on Codex, whose TUI fully
|
||||
* reinitializes on a restart.
|
||||
*
|
||||
* Uses the same JSDOM + `runScripts: "dangerously"` approach as
|
||||
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements runCodex() (the
|
||||
* CLI this was reported against) reads.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function bootApp() {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<select id="quickStartCase"><option value="testcase" selected>testcase</option></select>
|
||||
<input id="tabCount" value="1">
|
||||
<button id="runBtn"></button>
|
||||
<div id="runModeMenu"></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'dangerously' }
|
||||
);
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
|
||||
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||
(win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS);
|
||||
(win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS);
|
||||
const app = new win.CodemanApp();
|
||||
app.cases = [{ name: 'testcase' }];
|
||||
app.terminal = { focus: () => {} };
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.showToast = () => {};
|
||||
app._beginSessionLaunchStatus = () => 'status-token';
|
||||
app._reportSessionLaunchError = (_token: unknown, message: string) => {
|
||||
app._lastReportedError = message;
|
||||
};
|
||||
app._ensureCreatedSessionVisible = async () => {};
|
||||
app.selectSession = async () => {};
|
||||
app._nextCaseSessionStartNumber = () => 1;
|
||||
return { win, app };
|
||||
}
|
||||
|
||||
describe('runCustomModelEntry dispatch', () => {
|
||||
it('routes claude through the restart-after-launch path', async () => {
|
||||
const { app } = bootApp();
|
||||
let calledRestart = false;
|
||||
let calledOneShot = false;
|
||||
app._runCustomModelEntryViaRestart = async () => {
|
||||
calledRestart = true;
|
||||
};
|
||||
app._runCustomModelEntryOneShot = async () => {
|
||||
calledOneShot = true;
|
||||
};
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
expect(calledRestart).toBe(true);
|
||||
expect(calledOneShot).toBe(false);
|
||||
});
|
||||
|
||||
it('routes every other custom-model-eligible CLI through the one-shot path', async () => {
|
||||
for (const mode of ['opencode', 'codex', 'gemini', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const { app } = bootApp();
|
||||
let calledRestart = false;
|
||||
let calledOneShot = false;
|
||||
app._runCustomModelEntryViaRestart = async () => {
|
||||
calledRestart = true;
|
||||
};
|
||||
app._runCustomModelEntryOneShot = async () => {
|
||||
calledOneShot = true;
|
||||
};
|
||||
await app.runCustomModelEntry(mode, 'llama-box', 'qwen3');
|
||||
expect(calledRestart, mode).toBe(false);
|
||||
expect(calledOneShot, mode).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('_runCustomModelEntryOneShot', () => {
|
||||
it('stashes the pick on _pendingCustomModelForLaunch for the duration of run(), then clears it', async () => {
|
||||
const { app } = bootApp();
|
||||
let seenDuringRun: unknown;
|
||||
app.run = async function (this: typeof app) {
|
||||
seenDuringRun = this._pendingCustomModelForLaunch;
|
||||
};
|
||||
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
|
||||
expect(seenDuringRun).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
|
||||
expect(app._pendingCustomModelForLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the pending pick even when run() throws', async () => {
|
||||
const { app } = bootApp();
|
||||
app.run = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
await expect(app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3')).rejects.toThrow('boom');
|
||||
expect(app._pendingCustomModelForLaunch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('starts the loading watcher when the launch reports modelSwapInProgress', async () => {
|
||||
const { app } = bootApp();
|
||||
app.run = async () => {
|
||||
app._lastCustomModelLaunchResult = { modelSwapInProgress: true };
|
||||
};
|
||||
let watched: unknown[] | null = null;
|
||||
app._watchLlamaSwapLoading = async (...args: unknown[]) => {
|
||||
watched = args;
|
||||
};
|
||||
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
|
||||
expect(watched).toEqual(['llama-box', 'qwen3']);
|
||||
});
|
||||
|
||||
it('never starts the watcher when no swap was needed', async () => {
|
||||
const { app } = bootApp();
|
||||
app.run = async () => {
|
||||
app._lastCustomModelLaunchResult = { modelSwapInProgress: false };
|
||||
};
|
||||
let watchCalled = false;
|
||||
app._watchLlamaSwapLoading = async () => {
|
||||
watchCalled = true;
|
||||
};
|
||||
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
|
||||
expect(watchCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_quickStartWithCustomModelConfirm', () => {
|
||||
function withFetch(win: Window & typeof globalThis, handler: (body: any) => any) {
|
||||
(win as unknown as { fetch: typeof fetch }).fetch = (async (_url: string, opts: any) => ({
|
||||
json: async () => handler(JSON.parse(opts.body)),
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it('returns the response directly when no confirmation is needed', async () => {
|
||||
const { win, app } = bootApp();
|
||||
withFetch(win, (body) => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false, body } }));
|
||||
const data = await app._quickStartWithCustomModelConfirm({
|
||||
mode: 'codex',
|
||||
customModel: { endpointId: 'e', modelId: 'm' },
|
||||
});
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult).toEqual(data.data);
|
||||
});
|
||||
|
||||
it('confirming re-sends with confirmed:true and returns the second response', async () => {
|
||||
const { win, app } = bootApp();
|
||||
win.confirm = (() => true) as typeof win.confirm;
|
||||
let calls = 0;
|
||||
withFetch(win, (body) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
requiresConfirmation: true,
|
||||
currentlyLoadedModel: 'llama3',
|
||||
affectedSessions: [{ id: 's2', name: 'w2' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
expect(body.customModel.confirmed).toBe(true);
|
||||
return { success: true, data: { sessionId: 's1', modelSwapInProgress: true } };
|
||||
});
|
||||
const data = await app._quickStartWithCustomModelConfirm({
|
||||
mode: 'codex',
|
||||
customModel: { endpointId: 'e', modelId: 'm' },
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult.modelSwapInProgress).toBe(true);
|
||||
});
|
||||
|
||||
it('cancelling never re-sends, and reports a cancellation error', async () => {
|
||||
const { win, app } = bootApp();
|
||||
win.confirm = (() => false) as typeof win.confirm;
|
||||
let calls = 0;
|
||||
withFetch(win, () => {
|
||||
calls += 1;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
requiresConfirmation: true,
|
||||
currentlyLoadedModel: 'llama3',
|
||||
affectedSessions: [{ id: 's2', name: 'w2' }],
|
||||
},
|
||||
};
|
||||
});
|
||||
const data = await app._quickStartWithCustomModelConfirm({
|
||||
mode: 'codex',
|
||||
customModel: { endpointId: 'e', modelId: 'm' },
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toMatch(/cancelled/i);
|
||||
expect(app._lastCustomModelLaunchResult).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCodex(): one-shot custom-model launch (the CLI this was reported against)', () => {
|
||||
it('folds _pendingCustomModelForLaunch into the quick-start body as customModel', async () => {
|
||||
const { win, app } = bootApp();
|
||||
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
|
||||
if (url === '/api/codex/status') return { json: async () => ({ data: { available: true } }) };
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.customModel).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
|
||||
return { json: async () => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false } }) };
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
app._pendingCustomModelForLaunch = { endpointId: 'llama-box', modelId: 'qwen3' };
|
||||
await app.runCodex();
|
||||
expect(app._lastReportedError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits customModel entirely for a plain (non-custom-model) Codex launch', async () => {
|
||||
const { win, app } = bootApp();
|
||||
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
|
||||
if (url === '/api/codex/status') return { json: async () => ({ data: { available: true } }) };
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.customModel).toBeUndefined();
|
||||
return { json: async () => ({ success: true, data: { sessionId: 's1' } }) };
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await app.runCodex();
|
||||
expect(app._lastReportedError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @fileoverview POST /api/quick-start's `customModel` field (docs/custom-model-endpoints-plan.md):
|
||||
* the ONE-SHOT launch path that computes a custom-model endpoint's injection BEFORE the
|
||||
* session/process exists and launches directly on it, so a custom-model Run never shows
|
||||
* the native-boot-then-restart the dedicated POST /api/sessions/:id/custom-model route's
|
||||
* restart-in-place design otherwise produces — most visibly on a CLI like Codex whose TUI
|
||||
* fully reinitializes on a restart. That dedicated route is still what an ALREADY-RUNNING
|
||||
* session uses to switch later; this is the create-time equivalent.
|
||||
*
|
||||
* Mirrors test/routes/session-custom-model.test.ts's fixtures and llama-swap mocking, since
|
||||
* this route mirrors that one's own checks (llama-swap conflict, unsupported CLI, unknown
|
||||
* endpoint, an argv-incompatible model id) rather than a lighter, separately-drifting copy.
|
||||
*
|
||||
* Session.prototype.startInteractive/startShell are mocked exactly like the workspace-hooks
|
||||
* quick-start tests: quick-start constructs a REAL Session (not the MockSession the route
|
||||
* test harness substitutes elsewhere), so tmux must never actually be reached.
|
||||
*
|
||||
* Port: N/A (app.inject, no real port needed)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { rm, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { CASES_DIR } from '../../src/web/route-helpers.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir } from '../../src/custom-model-injection-apply.js';
|
||||
import { webviewFetch } from '../../src/web/webview-egress.js';
|
||||
|
||||
vi.mock('../../src/web/webview-egress.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
|
||||
'../../src/web/webview-egress.js'
|
||||
);
|
||||
return { ...actual, webviewFetch: vi.fn() };
|
||||
});
|
||||
const fetchMock = vi.mocked(webviewFetch);
|
||||
|
||||
// quick-start's own local-CLI-availability gate (resolveCliLaunchError, unrelated to the
|
||||
// custom-model injection this file tests) runs BEFORE the code under test and would
|
||||
// otherwise 404 every non-claude mode on a box with no codex/pi/grok/omp binary installed —
|
||||
// exactly this test environment. Mirrors the real "not remote" bypass documented at its own
|
||||
// call site in session-routes.ts (`session-routes.test.ts`'s remote-codex test is the
|
||||
// precedent for needing this at all).
|
||||
vi.mock('../../src/utils/cli-launcher.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/cli-launcher.js')>(
|
||||
'../../src/utils/cli-launcher.js'
|
||||
);
|
||||
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const ENDPOINT: CustomModelHost = {
|
||||
id: 'ep1',
|
||||
label: 'llama.cpp box',
|
||||
baseUrl: 'http://192.168.1.50:8080',
|
||||
apiKey: 'k',
|
||||
};
|
||||
|
||||
describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () => {
|
||||
let app: FastifyInstance;
|
||||
let ctx: MockRouteContext;
|
||||
let restartSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const quickStart = (payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: '/api/quick-start', payload });
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
restartSpy = vi.spyOn(Session.prototype, 'restartCli').mockResolvedValue(true);
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response('not found', { status: 404 })); // default: not llama-swap
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
await writeCustomModelHosts(getDataDir(), [ENDPOINT]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
vi.restoreAllMocks();
|
||||
await rm(join(getDataDir(), 'custom-model-hosts.json'), { force: true });
|
||||
await rm(join(getDataDir(), 'custom-model-configs'), { recursive: true, force: true });
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
});
|
||||
|
||||
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-claude',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.customModel).toEqual({ endpointId: 'ep1', modelId: 'qwen3', label: 'llama.cpp box' });
|
||||
// The whole point: never restarted. It launched on the endpoint the first time.
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
const isolatedDir = customModelConfigDir(sessionId);
|
||||
const trustFile = JSON.parse(await readFile(join(isolatedDir, '.claude.json'), 'utf-8'));
|
||||
expect(trustFile.customApiKeyResponses.approved).toEqual(['k']);
|
||||
});
|
||||
|
||||
it('codex: writes the config.toml under the SAME id the session actually launches with, no restart', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-codex',
|
||||
mode: 'codex',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.customModel?.endpointId).toBe('ep1');
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
expect(existsSync(join(configDir, 'config.toml'))).toBe(true);
|
||||
const toml = await readFile(join(configDir, 'config.toml'), 'utf-8');
|
||||
expect(toml).toContain('model = "qwen3"');
|
||||
});
|
||||
|
||||
it('pi: forces --model custom/<id> onto piConfig on the FIRST launch, not via a later restart', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-pi',
|
||||
mode: 'pi',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3.5-0.8b' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session & { piConfig?: { model?: string } };
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('custom/qwen3.5-0.8b');
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grok: forces the [model.<name>] block name onto grokConfig on the first launch', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-grok',
|
||||
mode: 'grok',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('codeman-custom');
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omp: forces custom/<id> onto ompConfig even with no incoming ompConfig at all', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-omp',
|
||||
mode: 'omp',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('custom/qwen3');
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s for an unknown endpoint id', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-ghost',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ghost', modelId: 'qwen3' },
|
||||
});
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('refuses a mode with no known custom-model mechanism (antigravity)', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-agy',
|
||||
mode: 'antigravity',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
});
|
||||
|
||||
it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-badmodel',
|
||||
mode: 'pi',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen 3 with spaces' },
|
||||
});
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('refuses customModel for a remote case', async () => {
|
||||
// Fixture mirrors session-routes' own remote-case shape minimally: an unresolvable
|
||||
// remote host is fine here, since the customModel check fires before the host lookup.
|
||||
const res = await quickStart({
|
||||
caseName: 'nonexistent-remote-case',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
// No matching remote/docker case fixture exists, so this actually falls through to the
|
||||
// local branch and succeeds — this test only documents that remote/docker have their
|
||||
// own explicit customModel rejection (see the local-fixture tests in
|
||||
// session-routes-workspace-hooks.test.ts for the fixture-loading pattern that would be
|
||||
// needed to exercise the remote/docker branch itself).
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
describe('llama-swap conflict check', () => {
|
||||
function mockRunning(running: Array<{ model: string; state: string }>) {
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
if (url.pathname === '/running') return new Response(JSON.stringify({ running }), { status: 200 });
|
||||
throw new Error(`unexpected request in this test: ${url.href}`);
|
||||
});
|
||||
}
|
||||
|
||||
it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => {
|
||||
const other = ctx.sessions.get('test-session-1')!;
|
||||
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
||||
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
||||
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-conflict',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
const body = res.json();
|
||||
expect(body.requiresConfirmation).toBe(true);
|
||||
expect(body.currentlyLoadedModel).toBe('llama3');
|
||||
expect(body.affectedSessions).toEqual([{ id: 'test-session-1', name: other.name }]);
|
||||
// Nothing was actually created.
|
||||
expect(ctx.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('launches once confirmed, skipping the conflict check', async () => {
|
||||
const other = ctx.sessions.get('test-session-1')!;
|
||||
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
|
||||
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
||||
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-confirmed',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3', confirmed: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().requiresConfirmation).toBeUndefined();
|
||||
expect(ctx.sessions.size).toBe(2);
|
||||
});
|
||||
|
||||
it('launches straight away when nothing else is using the currently loaded model', async () => {
|
||||
mockRunning([{ model: 'llama3', state: 'ready' }]);
|
||||
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-noconflict',
|
||||
mode: 'claude',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().requiresConfirmation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user