Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/cli/src/core/agents/catalog-ops.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock>
): Promise<CatalogOpsDeps> {
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",
});
});
});
18 changes: 16 additions & 2 deletions apps/cli/src/core/agents/catalog-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentPool } from "@/core/acp/pool";
import {
effortsFromSession,
findSelectConfigOptionId,
hasNativeModelSelection,
modelsFromSession,
modesFromSession,
personasFromSession,
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/src/core/agents/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
configOptionsToEfforts,
configOptionsToModels,
findSelectConfigOptionId,
hasNativeModelSelection,
modelsFromSession,
modesFromSession,
reconcileInvalidSelectConfigOptions,
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 7 additions & 3 deletions apps/cli/src/core/agents/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
53 changes: 48 additions & 5 deletions apps/cli/src/core/threads/coordinator.start-thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand All @@ -218,7 +236,7 @@ function createCoordinator() {
agentCapabilities: { loadSession: true },
}),
getSdkConnection: () => ({
setSessionConfigOption: mock(async () => undefined),
setSessionConfigOption,
}),
} as unknown as AgentPool;

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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'
Expand Down