fix(custom-model): keep the launch model out of the stored config

Quick-start wrote a selection's launchModel into the session's stored
<Mode>Config, which is what a clear falls back to, so a clear relaunched
the CLI with the endpoint's model id on its native backend. Only API
callers can reach that, since no page sends a clear.

The first launch now takes the forced model from the session's selection,
through the same _withCustomModelLaunchModel() the respawn path uses, so
the stored config keeps the model chosen at create. That also carries the
model onto a new tmux session for a selection applied to a running
session, which the create path used to lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 12:41:32 +01:00
1 parent fc25bf5f83
commit a651616907
6 files changed
+95 -32

No files matched your search

@@ -204,6 +204,34 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(restartSpy).not.toHaveBeenCalled();
});
it("leaves the caller's own model in the stored config, which is what a later clear falls back to", async () => {
const res = await quickStart({
caseName: 'cm-pi-own-model',
mode: 'pi',
piConfig: { model: 'anthropic/claude-x' },
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(session.toState().piConfig?.model).toBe('anthropic/claude-x');
});
it('stores no model at all for a caller that chose none', async () => {
const res = await quickStart({
caseName: 'cm-codex-no-model',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
});
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(session.toState().codexConfig?.model).toBeUndefined();
});
it('404s for an unknown endpoint id', async () => {
const res = await quickStart({
caseName: 'cm-ghost',
+47
View File
@@ -195,6 +195,53 @@ describe('restartCli() must not kill a working pane', () => {
});
describe('launchModel reaches the CLI through its own launch param', () => {
/** A session the route just built: no pane yet, so its launch is `createSession`. */
function freshSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
mkdirSync(workingDir, { recursive: true });
const mux = new TmuxManager();
const create = vi.spyOn(mux, 'createSession');
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
const session = new Session({ workingDir, mode, mux, useMux: true, ...extra });
sessions.push(session);
return { session, create, respawn };
}
it('the first launch carries it too, and a clear relaunches on the model the session was created with', async () => {
const { session, create, respawn } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x' } });
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
{ HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig?.model).toBe('custom/qwen3');
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].piConfig?.model).toBe('anthropic/claude-x');
});
it('a clear on a session created with no model relaunches with none', async () => {
const { session, create, respawn } = freshSession('codex');
session.setCustomModel(
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], configDir: '/tmp/cfg', launchModel: 'qwen3' },
{ CODEX_HOME: '/tmp/cfg' }
);
await session.startInteractive();
expect(create.mock.calls[0][0].codexConfig?.model).toBe('qwen3');
session.setCustomModel(undefined);
expect(await session.restartCli()).toBe(true);
expect(respawn.mock.calls[0][0].codexConfig?.model).toBeUndefined();
});
it('leaves the launch untouched without a selection', async () => {
const { session, create } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
await session.startInteractive();
expect(create.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
});
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
session.setCustomModel(