diff --git a/apps/cli/src/core/agents/catalog-ops.test.ts b/apps/cli/src/core/agents/catalog-ops.test.ts new file mode 100644 index 00000000..c8561888 --- /dev/null +++ b/apps/cli/src/core/agents/catalog-ops.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { RuntimeSession } from "@acp-kit/core"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { AgentPool } from "@/core/acp/pool"; +import { type CatalogOpsDeps, setCatalogField } from "./catalog-ops"; +import { SessionMetadataStore } from "./metadata"; +import { ThreadSessionStore } from "./sessions"; + +function createMockSession( + sessionId: string, + options: { native: boolean } +): RuntimeSession { + const configOptions: SessionConfigOption[] = options.native + ? [] + : ([ + { + type: "select", + id: "model-config", + category: "model", + name: "Model", + currentValue: "a", + options: [ + { value: "a", name: "A" }, + { value: "b", name: "B" }, + ], + }, + ] as SessionConfigOption[]); + + return { + sessionId, + transcript: { + session: { + models: options.native + ? { availableModels: [{ modelId: "model-1", name: "Model 1" }] } + : undefined, + modes: { availableModes: [] }, + configOptions, + }, + }, + close: mock(async () => undefined), + setModel: mock(async () => undefined), + setMode: mock(async () => undefined), + on: () => () => undefined, + } as unknown as RuntimeSession; +} + +async function createDeps( + session: RuntimeSession, + setSessionConfigOption: ReturnType +): Promise { + const pool = { + getState: () => "ready", + getRuntime: async () => ({ + newSession: () => Promise.resolve(session), + agentCapabilities: { loadSession: true }, + }), + getSdkConnection: () => ({ setSessionConfigOption }), + } as unknown as AgentPool; + + const sessions = new ThreadSessionStore( + "mock-agent", + pool, + new SessionMetadataStore() + ); + await sessions.createBoundSession("thread-1", "project-1", "/tmp/project"); + + return { agentName: "mock-agent", pool, sessions }; +} + +describe("setCatalogField(model)", () => { + test("calls native session/set_model when the agent exposes availableModels", async () => { + const session = createMockSession("session-1", { native: true }); + const setSessionConfigOption = mock(async () => undefined); + const deps = await createDeps(session, setSessionConfigOption); + + await setCatalogField( + deps, + "model", + "thread-1", + "project-1", + "/tmp/project", + "session-1", + "model-1" + ); + + expect(session.setModel).toHaveBeenCalledWith("model-1"); + expect(setSessionConfigOption).not.toHaveBeenCalled(); + }); + + test("routes through the config-option fallback instead of session/set_model when the agent has no native model support", async () => { + const session = createMockSession("session-1", { native: false }); + const setSessionConfigOption = mock(async () => undefined); + const deps = await createDeps(session, setSessionConfigOption); + + await setCatalogField( + deps, + "model", + "thread-1", + "project-1", + "/tmp/project", + "session-1", + "b" + ); + + expect(session.setModel).not.toHaveBeenCalled(); + expect(setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "session-1", + configId: "model-config", + value: "b", + }); + }); +}); diff --git a/apps/cli/src/core/agents/catalog-ops.ts b/apps/cli/src/core/agents/catalog-ops.ts index 3a72499c..0aa53900 100644 --- a/apps/cli/src/core/agents/catalog-ops.ts +++ b/apps/cli/src/core/agents/catalog-ops.ts @@ -6,6 +6,7 @@ import type { AgentPool } from "@/core/acp/pool"; import { effortsFromSession, findSelectConfigOptionId, + hasNativeModelSelection, modelsFromSession, modesFromSession, personasFromSession, @@ -115,9 +116,22 @@ async function setModel( cwd, sessionId ); + + if (!hasNativeModelSelection(session)) { + await setConfigOptionByCategory( + deps, + threadId, + projectId, + cwd, + sessionId, + "model", + modelId + ); + return; + } + await session.setModel(modelId); - // Model is already applied — reconcile is best-effort so a dependent - // reset failure does not report setModel as failed (client refreshes on ok). + await Result.tryPromise(() => reconcileDependentConfigOptions( deps, diff --git a/apps/cli/src/core/agents/catalog.test.ts b/apps/cli/src/core/agents/catalog.test.ts index a3a68b24..f79a570b 100644 --- a/apps/cli/src/core/agents/catalog.test.ts +++ b/apps/cli/src/core/agents/catalog.test.ts @@ -5,6 +5,7 @@ import { configOptionsToEfforts, configOptionsToModels, findSelectConfigOptionId, + hasNativeModelSelection, modelsFromSession, modesFromSession, reconcileInvalidSelectConfigOptions, @@ -65,6 +66,33 @@ describe("modelsFromSession", () => { }); }); +describe("hasNativeModelSelection", () => { + test("true when the session reports availableModels", () => { + expect( + hasNativeModelSelection( + sessionWith({ + models: { + currentModelId: "m1", + availableModels: [{ modelId: "m1", name: "Model 1" }], + }, + }) + ) + ).toBe(true); + }); + + test("false when the session has no models block", () => { + expect(hasNativeModelSelection(sessionWith({}))).toBe(false); + }); + + test("false when availableModels is empty", () => { + expect( + hasNativeModelSelection( + sessionWith({ models: { currentModelId: "m1", availableModels: [] } }) + ) + ).toBe(false); + }); +}); + describe("modesFromSession / efforts", () => { test("maps available modes", () => { const modes = modesFromSession( diff --git a/apps/cli/src/core/agents/catalog.ts b/apps/cli/src/core/agents/catalog.ts index 312475f1..946f907a 100644 --- a/apps/cli/src/core/agents/catalog.ts +++ b/apps/cli/src/core/agents/catalog.ts @@ -23,9 +23,8 @@ export function catalogFromSession(session: RuntimeSession): AgentCatalog { export function modelsFromSession(session: RuntimeSession): ModelOption[] { const { session: meta } = session.transcript; - const fromModels = meta.models?.availableModels; - if (fromModels && fromModels.length > 0) { - return fromModels.map((model) => ({ + if (hasNativeModelSelection(session)) { + return (meta.models?.availableModels ?? []).map((model) => ({ id: model.modelId, name: model.name, description: model.description ?? null, @@ -35,6 +34,11 @@ export function modelsFromSession(session: RuntimeSession): ModelOption[] { return configOptionsToModels(meta.configOptions); } +export function hasNativeModelSelection(session: RuntimeSession): boolean { + const availableModels = session.transcript.session.models?.availableModels; + return Boolean(availableModels && availableModels.length > 0); +} + export function modesFromSession(session: RuntimeSession): SelectOption[] { return modesToOptions(catalogFromSession(session).modes); } diff --git a/apps/cli/src/core/threads/coordinator.start-thread.test.ts b/apps/cli/src/core/threads/coordinator.start-thread.test.ts index f73ff965..749f9b3c 100644 --- a/apps/cli/src/core/threads/coordinator.start-thread.test.ts +++ b/apps/cli/src/core/threads/coordinator.start-thread.test.ts @@ -28,19 +28,35 @@ const ops: string[] = []; let projectCwd = "/tmp/project"; let sessionCreateError: Error | null = null; let setModelError: Error | null = null; +let nextSessionHasNativeModel = true; const sessions: RuntimeSession[] = []; let nextSessionCwd: string | undefined; function createMockSession(sessionId: string): RuntimeSession { + const hasNativeModel = nextSessionHasNativeModel; return { sessionId, transcript: { session: { - models: { - availableModels: [{ modelId: "model-1", name: "Model 1" }], - }, + models: hasNativeModel + ? { availableModels: [{ modelId: "model-1", name: "Model 1" }] } + : undefined, modes: { availableModes: [{ id: "mode-1", name: "Mode 1" }] }, - configOptions: [], + configOptions: hasNativeModel + ? [] + : [ + { + type: "select", + id: "model-config", + category: "model", + name: "Model", + currentValue: "model-1", + options: [ + { value: "model-1", name: "Model 1" }, + { value: "model-2", name: "Model 2" }, + ], + }, + ], }, }, close: mock(async () => undefined), @@ -201,6 +217,8 @@ mock.module("@/git/checkout", () => ({ checkoutGitRef: () => Promise.resolve(Result.ok(undefined)), })); +let setSessionConfigOption = mock(async () => undefined); + function createCoordinator() { const pool = { getState: () => "ready", @@ -218,7 +236,7 @@ function createCoordinator() { agentCapabilities: { loadSession: true }, }), getSdkConnection: () => ({ - setSessionConfigOption: mock(async () => undefined), + setSessionConfigOption, }), } as unknown as AgentPool; @@ -233,8 +251,10 @@ describe("startThread", () => { sessions.length = 0; sessionCreateError = null; setModelError = null; + nextSessionHasNativeModel = true; nextSessionCwd = undefined; projectCwd = "/tmp/project"; + setSessionConfigOption = mock(async () => undefined); }); test("births a thread with a bound session ready to prompt", async () => { @@ -379,6 +399,29 @@ describe("startThread", () => { expect(sessions[0]?.setModel).toHaveBeenCalledWith("model-1"); }); + test("applies model preference through the config-option fallback for agents without native session/set_model", async () => { + nextSessionHasNativeModel = false; + const coordinator = createCoordinator(); + + const started = await coordinator.startThread({ + projectId: "project-1", + agentName: "mock-agent", + message: [{ type: "text", text: "config-option model" }], + preferences: { modelId: "model-2" }, + turnId: "turn-prefs-fallback", + }); + + expect(started.isOk()).toBe(true); + if (started.isErr()) throw new Error("expected ok"); + expect(started.value.bound).not.toBeNull(); + expect(sessions[0]?.setModel).not.toHaveBeenCalled(); + expect(setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "session-1", + configId: "model-config", + value: "model-2", + }); + }); + test("preference apply failure persists the message and leaves bound null", async () => { setModelError = new Error( 'Unhandled exception: "Method not found": session/set_model'