Preserve Claude model and thinking preferences (#2912)
* fix(agents): preserve Claude thinking selections Model switches now resolve thinking against the target model's saved preference instead of resetting to the catalog default. Claude owns its configured-model defaults and legacy Fable alias handling, while persisted permission modes remain unchanged. * chore(claude): tag Fable compatibility shim * fix(app): resolve persisted model aliases * fix(models): preserve aliases for older clients * fix(models): hide compatibility aliases from listings * fix(settings): hide compatibility model aliases * test(claude): cover legacy catalog entry * test(app): expect mock thinking controls in schedules * fix(app): preserve queued thinking preferences * fix(app): reconcile schedule provider snapshots * fix(app): preserve schedule model selections * fix(claude): preserve exact configured model ids * fix(app): preserve committed thinking preferences * fix(app): preserve aliased schedule thinking
This commit is contained in:
@@ -75,6 +75,8 @@ export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
||||
export interface AgentModelDefinition {
|
||||
provider: AgentProvider;
|
||||
id: string;
|
||||
aliases?: string[];
|
||||
isSelectable?: boolean;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
@@ -101,6 +103,12 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen
|
||||
return { ...model, defaultThinkingOptionId };
|
||||
}
|
||||
|
||||
export function filterSelectableAgentModels(
|
||||
models: AgentModelDefinition[] | undefined,
|
||||
): AgentModelDefinition[] {
|
||||
return models?.filter((model) => model.isSelectable !== false) ?? [];
|
||||
}
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
@@ -703,6 +711,8 @@ export interface AgentClient {
|
||||
* The registry is responsible for merging configured model overrides.
|
||||
*/
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
/** Apply provider-owned defaults to a model supplied through provider configuration. */
|
||||
resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition;
|
||||
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
|
||||
@@ -10,6 +10,16 @@ import type {
|
||||
ProviderCatalog,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
const CLAUDE_CUSTOM_THINKING_FIELDS = {
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
{ id: "max", label: "Max" },
|
||||
],
|
||||
defaultThinkingOptionId: "high",
|
||||
} satisfies Partial<AgentModelDefinition>;
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
interface ConstructorEntry {
|
||||
runtimeSettings?: unknown;
|
||||
@@ -64,55 +74,62 @@ vi.mock("../../executable-resolution/executable-resolution.js", () => ({
|
||||
isCommandAvailable: mockState.isCommandAvailable,
|
||||
}));
|
||||
|
||||
vi.mock("./providers/claude/agent.js", () => ({
|
||||
ClaudeAgentClient: class ClaudeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "claude";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.claude.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
vi.mock("./providers/claude/agent.js", async () => {
|
||||
const { resolveConfiguredClaudeModel } = await import("./providers/claude/models.js");
|
||||
return {
|
||||
ClaudeAgentClient: class ClaudeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
}
|
||||
readonly provider = "claude";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
? Reflect.get(this.runtimeSettings, "command")
|
||||
: undefined;
|
||||
if (command?.mode === "replace") {
|
||||
const { isCommandAvailable } =
|
||||
await import("../../executable-resolution/executable-resolution.js");
|
||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.claude.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
return resolveConfiguredClaudeModel(model);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
? Reflect.get(this.runtimeSettings, "command")
|
||||
: undefined;
|
||||
if (command?.mode === "replace") {
|
||||
const { isCommandAvailable } =
|
||||
await import("../../executable-resolution/executable-resolution.js");
|
||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
CodexAppServerAgentClient: class CodexAppServerAgentClient {
|
||||
@@ -1114,6 +1131,7 @@ describe("model merging", () => {
|
||||
provider: "claude",
|
||||
id: "profile-fast",
|
||||
label: "Profile Fast",
|
||||
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1160,11 +1178,13 @@ describe("model merging", () => {
|
||||
provider: "claude",
|
||||
id: "shared-model",
|
||||
label: "Profile Label",
|
||||
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "profile-model",
|
||||
label: "Profile Model",
|
||||
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1249,6 +1269,7 @@ describe("model merging", () => {
|
||||
id: "shared-model",
|
||||
label: "Profile Label",
|
||||
description: "Runtime description",
|
||||
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
metadata: {
|
||||
source: "runtime",
|
||||
},
|
||||
@@ -1314,6 +1335,7 @@ describe("model merging", () => {
|
||||
id: "profile-default",
|
||||
label: "Profile Default",
|
||||
isDefault: true,
|
||||
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1345,6 +1367,45 @@ describe("model merging", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("Claude configured models can override or disable inferred thinking options", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
models: [
|
||||
{ id: "custom-defaults", label: "Defaults" },
|
||||
{ id: "claude-sonnet-5", label: "Known" },
|
||||
{ id: "claude-opus-5", label: "Disabled", thinkingOptions: [] },
|
||||
{
|
||||
id: "custom-explicit",
|
||||
label: "Explicit",
|
||||
thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.find((model) => model.id === "custom-defaults")).toMatchObject(
|
||||
CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||
);
|
||||
expect(
|
||||
models
|
||||
.find((model) => model.id === "claude-sonnet-5")
|
||||
?.thinkingOptions?.map((option) => option.id),
|
||||
).toEqual(["off", "low", "medium", "high", "xhigh", "max", "ultracode"]);
|
||||
expect(models.find((model) => model.id === "claude-opus-5")?.thinkingOptions).toEqual([]);
|
||||
expect(models.find((model) => model.id === "custom-explicit")).toMatchObject({
|
||||
thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }],
|
||||
defaultThinkingOptionId: "bespoke",
|
||||
});
|
||||
});
|
||||
|
||||
test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => {
|
||||
mockState.runtimeModels.set("codex", [
|
||||
{
|
||||
@@ -1415,6 +1476,39 @@ describe("model merging", () => {
|
||||
expect(defaultModel?.id).toBe("profile-default");
|
||||
});
|
||||
|
||||
test("explicit additional models override hidden compatibility entries", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5[1m]",
|
||||
label: "Fable 5",
|
||||
isSelectable: false,
|
||||
},
|
||||
]);
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
additionalModels: [{ id: "claude-fable-5[1m]", label: "Gateway Fable 5" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-fable-5[1m]",
|
||||
label: "Gateway Fable 5",
|
||||
isSelectable: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{ provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true },
|
||||
|
||||
@@ -287,6 +287,17 @@ function mapModel(
|
||||
return normalizeAgentModelDefinition({ ...model, provider });
|
||||
}
|
||||
|
||||
function resolveConfiguredModels(
|
||||
provider: AgentProvider,
|
||||
client: AgentClient,
|
||||
models: ProviderProfileModel[],
|
||||
): AgentModelDefinition[] {
|
||||
return models.map((model) => {
|
||||
const mapped = mapModel(provider, model);
|
||||
return client.resolveConfiguredModel?.(mapped) ?? mapped;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeModels(
|
||||
provider: AgentProvider,
|
||||
profileModels: ProviderProfileModel[],
|
||||
@@ -309,7 +320,7 @@ function mergeModels(
|
||||
function mergeModelAdditions(
|
||||
provider: AgentProvider,
|
||||
baseModels: AgentModelDefinition[],
|
||||
modelAdditions: ProviderProfileModel[],
|
||||
modelAdditions: Array<ProviderProfileModel | AgentModelDefinition>,
|
||||
): AgentModelDefinition[] {
|
||||
if (modelAdditions.length === 0) {
|
||||
return baseModels;
|
||||
@@ -328,9 +339,13 @@ function mergeModelAdditions(
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingModel = mergedModels[existingIndex];
|
||||
const explicitlyEnablesCompatibilityModel =
|
||||
existingModel?.isSelectable === false && additionalModel.isSelectable === undefined;
|
||||
mergedModels[existingIndex] = {
|
||||
...mergedModels[existingIndex],
|
||||
...existingModel,
|
||||
...additionalModel,
|
||||
...(explicitlyEnablesCompatibilityModel ? { isSelectable: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -444,6 +459,7 @@ function wrapClientProvider(
|
||||
})
|
||||
: undefined,
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
listFeatures: listFeatures
|
||||
? async (config) => await listFeatures({ ...config, provider: inner.provider })
|
||||
@@ -490,10 +506,15 @@ function createRegistryEntry(
|
||||
resolved: ResolvedProvider,
|
||||
): ProviderDefinition {
|
||||
const modelClient = resolved.createBaseClient(logger);
|
||||
const hasReplacementModels =
|
||||
resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive;
|
||||
const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels);
|
||||
const additionalModels = resolveConfiguredModels(
|
||||
provider,
|
||||
modelClient,
|
||||
resolved.additionalModels,
|
||||
);
|
||||
const hasReplacementModels = profileModels.length > 0 && !resolved.profileModelsAreAdditive;
|
||||
const replacementModels = hasReplacementModels
|
||||
? resolved.profileModels.map((model) => mapModel(provider, model))
|
||||
? profileModels.map((model) => mapModel(provider, model))
|
||||
: [];
|
||||
|
||||
const decorateModes = (modes: AgentMode[]): AgentMode[] =>
|
||||
@@ -524,7 +545,7 @@ function createRegistryEntry(
|
||||
// Replacement models skip runtime model discovery, but additionalModels
|
||||
// must still be merged on top. If modes are dynamic, probe for modes via
|
||||
// the single catalog API; otherwise use static/empty modes with no runtime.
|
||||
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
|
||||
const models = mergeModelAdditions(provider, replacementModels, additionalModels);
|
||||
if (hasStaticModes) {
|
||||
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
|
||||
config: {
|
||||
@@ -545,15 +566,9 @@ function createRegistryEntry(
|
||||
const catalog = await catalogClient.fetchCatalog(options);
|
||||
return {
|
||||
...catalog,
|
||||
models: mergeModels(
|
||||
provider,
|
||||
resolved.profileModels,
|
||||
resolved.additionalModels,
|
||||
catalog.models,
|
||||
{
|
||||
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||
},
|
||||
),
|
||||
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||
}),
|
||||
modes: decorateModes(catalog.modes),
|
||||
};
|
||||
},
|
||||
@@ -566,16 +581,17 @@ function createResolvedProviderClient(
|
||||
resolved: ResolvedProvider,
|
||||
): AgentClient {
|
||||
const inner = resolved.createBaseClient(logger);
|
||||
const hasModelOverrides =
|
||||
resolved.profileModels.length > 0 || resolved.additionalModels.length > 0;
|
||||
const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels);
|
||||
const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels);
|
||||
const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0;
|
||||
if (inner.provider === provider && !hasModelOverrides) {
|
||||
return inner;
|
||||
}
|
||||
return wrapClientProvider(
|
||||
provider,
|
||||
inner,
|
||||
resolved.profileModels,
|
||||
resolved.additionalModels,
|
||||
profileModels,
|
||||
additionalModels,
|
||||
resolved.profileModelsAreAdditive,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,6 +440,38 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("listModels excludes compatibility-only catalog entries", async () => {
|
||||
const client = createExtraClient("codex", {
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5.4", label: "GPT 5.4" },
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-legacy",
|
||||
label: "GPT 5.4 legacy",
|
||||
isSelectable: false,
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
}),
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: { codex: client },
|
||||
});
|
||||
try {
|
||||
const models = await manager.listModels({
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
wait: true,
|
||||
});
|
||||
expect(models.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("listModes rejects when the provider is disabled", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
|
||||
@@ -6,14 +6,15 @@ import type { Logger } from "pino";
|
||||
|
||||
import { expandTilde } from "../../utils/path.js";
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateConfigParent,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
FetchCatalogOptions,
|
||||
ProviderSnapshotEntry,
|
||||
import {
|
||||
filterSelectableAgentModels,
|
||||
type AgentClient,
|
||||
type AgentCreateConfigParent,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentProvider,
|
||||
type FetchCatalogOptions,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
@@ -317,7 +318,7 @@ export class ProviderSnapshotManager {
|
||||
|
||||
async listModels(input: ProviderSnapshotProviderOptions): Promise<AgentModelDefinition[]> {
|
||||
const entry = await this.getReadyProvider(input);
|
||||
return entry.models ?? [];
|
||||
return filterSelectableAgentModels(entry.models);
|
||||
}
|
||||
|
||||
async listModes(input: ProviderSnapshotProviderOptions): Promise<AgentMode[]> {
|
||||
|
||||
@@ -417,8 +417,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
@@ -431,6 +431,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
expect(models.find((model) => model.id === "claude-fable-5[1m]")?.isSelectable).toBe(false);
|
||||
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("claude");
|
||||
@@ -461,7 +462,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
});
|
||||
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]");
|
||||
expect(models.map((model) => model.id)).toContain("claude-fable-5");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -626,6 +627,31 @@ describe("ClaudeAgentSession features", () => {
|
||||
return { queryFactory, queryMock, launches };
|
||||
}
|
||||
|
||||
test("passes exact configured Fable 5 IDs through to Claude Code", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-fable-5[1m]",
|
||||
});
|
||||
|
||||
await (
|
||||
session as unknown as {
|
||||
ensureQuery(): Promise<unknown>;
|
||||
}
|
||||
).ensureQuery();
|
||||
expect(queryFactory.mock.calls[0]?.[0].options.model).toBe("claude-fable-5[1m]");
|
||||
|
||||
await session.setModel?.("claude-fable-5[1m]");
|
||||
expect(queryMock.setModel).toHaveBeenCalledWith("claude-fable-5[1m]");
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists fast mode only for supported Opus models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
@@ -751,7 +777,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
test.each([
|
||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive" }, "low"],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"],
|
||||
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
||||
["provider default", null, undefined, undefined],
|
||||
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
findClaudeModel,
|
||||
getClaudeModelsWithSettings,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
resolveConfiguredClaudeModel,
|
||||
} from "./models.js";
|
||||
import {
|
||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||
@@ -85,6 +86,7 @@ import {
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionRequestKind,
|
||||
type AgentPermissionResponse,
|
||||
@@ -1467,6 +1469,10 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.configDir = options.configDir;
|
||||
}
|
||||
|
||||
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
return resolveConfiguredClaudeModel(model);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -1629,7 +1635,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
if (config.provider !== "claude") {
|
||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: "claude" } as ClaudeAgentConfig;
|
||||
const model = config.model?.trim();
|
||||
return {
|
||||
...config,
|
||||
provider: "claude",
|
||||
model: model || undefined,
|
||||
} as ClaudeAgentConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2287,7 +2298,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
|
||||
const activeQuery = await this.ensureQuery();
|
||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
|
||||
@@ -4,6 +4,7 @@ type ClaudeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
interface ClaudeModelManifestEntry {
|
||||
id: string;
|
||||
aliases?: readonly string[];
|
||||
label: string;
|
||||
description: string;
|
||||
defaultPriority?: number;
|
||||
@@ -27,6 +28,8 @@ const CLAUDE_EFFORT_LABELS = {
|
||||
max: "Max",
|
||||
} as const satisfies Record<ClaudeEffortLevel, string>;
|
||||
|
||||
export const CLAUDE_DEFAULT_THINKING_OPTION_ID = "high";
|
||||
|
||||
export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
|
||||
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
||||
|
||||
@@ -41,20 +44,14 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5[1m]",
|
||||
label: "Fable 5 1M",
|
||||
description: "Fable 5 with 1M context window",
|
||||
minimumClaudeCodeVersion: "2.1.169",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
|
||||
aliases: ["claude-fable-5[1m]"],
|
||||
label: "Fable 5",
|
||||
description: "Fable 5 · Most powerful model",
|
||||
minimumClaudeCodeVersion: "2.1.169",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
@@ -165,6 +162,7 @@ function buildThinkingOptions(
|
||||
...effortLevels.map((id) => ({
|
||||
id,
|
||||
label: CLAUDE_EFFORT_LABELS[id],
|
||||
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -185,7 +183,8 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
undefined,
|
||||
);
|
||||
|
||||
return availableModels.map((model) => {
|
||||
const definitions: AgentModelDefinition[] = [];
|
||||
for (const model of availableModels) {
|
||||
const thinkingOptions = buildThinkingOptions(
|
||||
model.effortLevels,
|
||||
model.supportsThinkingDisabled === true,
|
||||
@@ -196,6 +195,9 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
};
|
||||
if ("aliases" in model && model.aliases) {
|
||||
definition.aliases = [...model.aliases];
|
||||
}
|
||||
if (model === defaultModel) {
|
||||
definition.isDefault = true;
|
||||
}
|
||||
@@ -204,10 +206,24 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
}
|
||||
if (thinkingOptions) {
|
||||
definition.thinkingOptions = thinkingOptions;
|
||||
definition.defaultThinkingOptionId = model.effortLevels?.[0];
|
||||
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||
}
|
||||
return definition;
|
||||
});
|
||||
definitions.push(definition);
|
||||
if (!("aliases" in model) || !model.aliases) {
|
||||
continue;
|
||||
}
|
||||
// COMPAT(claudeFable5LegacyCatalogEntry): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 apps are outside support.
|
||||
for (const alias of model.aliases) {
|
||||
definitions.push({
|
||||
...definition,
|
||||
id: alias,
|
||||
aliases: undefined,
|
||||
isDefault: undefined,
|
||||
isSelectable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function isModelAvailableInClaudeCode(
|
||||
@@ -262,7 +278,7 @@ export function resolveClaudeDisabledThinkingForModel(
|
||||
supported:
|
||||
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
|
||||
fallbackThinkingOptionId:
|
||||
model && "effortLevels" in model ? model.effortLevels?.[0] : undefined,
|
||||
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,6 +384,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
);
|
||||
}
|
||||
|
||||
export function getClaudeCustomModelThinkingOptions(): AgentSelectOption[] {
|
||||
return CLAUDE_EFFORT_LEVELS.standard.map((id) => {
|
||||
const option: AgentSelectOption = { id, label: CLAUDE_EFFORT_LABELS[id] };
|
||||
if (id === CLAUDE_DEFAULT_THINKING_OPTION_ID) option.isDefault = true;
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSingleSegmentClaudeModelId(
|
||||
familyValue: string,
|
||||
major: string,
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("getClaudeModels", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
@@ -82,8 +82,8 @@ describe("getClaudeModels", () => {
|
||||
expect(contextWindows).toEqual(
|
||||
new Map([
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-fable-5", 1_000_000],
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
["claude-fable-5", 200_000],
|
||||
["claude-opus-4-8[1m]", 1_000_000],
|
||||
["claude-opus-4-8", 200_000],
|
||||
["claude-sonnet-5", 200_000],
|
||||
@@ -105,9 +105,7 @@ describe("getClaudeModels", () => {
|
||||
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
|
||||
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
|
||||
|
||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]");
|
||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
|
||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5[1m]");
|
||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
|
||||
});
|
||||
|
||||
@@ -138,7 +136,7 @@ describe("getClaudeModels", () => {
|
||||
?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID)
|
||||
?.label,
|
||||
).toBe("Ultra Code");
|
||||
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low");
|
||||
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("high");
|
||||
expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual(
|
||||
models.get("claude-sonnet-5")?.thinkingOptions,
|
||||
);
|
||||
@@ -162,19 +160,16 @@ describe("getClaudeModels", () => {
|
||||
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||
);
|
||||
expect(models.get("claude-fable-5[1m]")?.thinkingOptions).toEqual(
|
||||
models.get("claude-fable-5")?.thinkingOptions,
|
||||
);
|
||||
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-opus-5", true, "low"],
|
||||
["claude-opus-5-20260724", true, "low"],
|
||||
["claude-sonnet-5", true, "low"],
|
||||
["claude-sonnet-5[1m]", true, "low"],
|
||||
["claude-sonnet-5-20260101", true, "low"],
|
||||
["claude-fable-5", false, "low"],
|
||||
["claude-opus-5", true, "high"],
|
||||
["claude-opus-5-20260724", true, "high"],
|
||||
["claude-sonnet-5", true, "high"],
|
||||
["claude-sonnet-5[1m]", true, "high"],
|
||||
["claude-sonnet-5-20260101", true, "high"],
|
||||
["claude-fable-5", false, "high"],
|
||||
["claude-haiku-4-5", false, undefined],
|
||||
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
||||
[null, false, undefined],
|
||||
@@ -327,6 +322,26 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets an exact settings model override the hidden Fable compatibility entry", async () => {
|
||||
const configDir = await createClaudeConfigDir({ model: "claude-fable-5[1m]" });
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = createCatalogClient();
|
||||
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
const configured = models.filter((model) => model.id === "claude-fable-5[1m]");
|
||||
expect(configured).toHaveLength(1);
|
||||
expect(configured[0]).toMatchObject({
|
||||
id: "claude-fable-5[1m]",
|
||||
isSelectable: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits models that require a newer Claude Code version", async () => {
|
||||
const client = createCatalogClient("2.1.218");
|
||||
|
||||
@@ -345,7 +360,7 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
it("returns exact match for known model IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||
@@ -361,14 +376,14 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
|
||||
"claude-sonnet-5[1m]",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves [1m] suffix from runtime model strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
||||
it("preserves [1m] only when it identifies a distinct catalog entry", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
});
|
||||
@@ -435,11 +450,47 @@ describe("Claude Opus 5 catalog", () => {
|
||||
it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => {
|
||||
expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({
|
||||
supported: true,
|
||||
fallbackThinkingOptionId: "low",
|
||||
fallbackThinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Fable 5 catalog", () => {
|
||||
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
|
||||
const fable5Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-fable-5"))
|
||||
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
|
||||
id,
|
||||
aliases,
|
||||
isSelectable,
|
||||
label,
|
||||
contextWindowMaxTokens,
|
||||
}));
|
||||
|
||||
expect(fable5Models).toEqual([
|
||||
{
|
||||
id: "claude-fable-5",
|
||||
aliases: ["claude-fable-5[1m]"],
|
||||
isSelectable: undefined,
|
||||
label: "Fable 5",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5[1m]",
|
||||
aliases: undefined,
|
||||
isSelectable: false,
|
||||
label: "Fable 5",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves retired Fable 5 IDs to the canonical catalog entry", () => {
|
||||
expect(findClaudeModel("claude-fable-5[1m]")?.id).toBe("claude-fable-5");
|
||||
expect(findClaudeModel("claude-fable-5-20260301[1m]")?.id).toBe("claude-fable-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
getClaudeCustomModelThinkingOptions,
|
||||
getClaudeManifestModels,
|
||||
normalizeClaudeManifestModelId,
|
||||
normalizeClaudeRuntimeModelId as normalizeClaudeManifestRuntimeModelId,
|
||||
} from "./model-manifest.js";
|
||||
|
||||
@@ -21,6 +23,21 @@ export function getClaudeModels(claudeCodeVersion?: string): AgentModelDefinitio
|
||||
return getClaudeManifestModels(claudeCodeVersion);
|
||||
}
|
||||
|
||||
export function resolveConfiguredClaudeModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
if (model.thinkingOptions !== undefined) return model;
|
||||
|
||||
const manifestModelId = normalizeClaudeManifestModelId(model.id);
|
||||
const manifestModel = manifestModelId
|
||||
? getClaudeModels().find((candidate) => candidate.id === manifestModelId)
|
||||
: undefined;
|
||||
if (manifestModel) {
|
||||
return manifestModel.thinkingOptions
|
||||
? { ...model, thinkingOptions: manifestModel.thinkingOptions }
|
||||
: model;
|
||||
}
|
||||
return { ...model, thinkingOptions: getClaudeCustomModelThinkingOptions() };
|
||||
}
|
||||
|
||||
export function findClaudeModel(
|
||||
modelId: string | null | undefined,
|
||||
): AgentModelDefinition | undefined {
|
||||
@@ -42,14 +59,17 @@ export async function getClaudeModelsWithSettings(
|
||||
return hardcodedModels;
|
||||
}
|
||||
|
||||
const seenModelIds = new Set(hardcodedModels.map((model) => model.id));
|
||||
const models = [...hardcodedModels];
|
||||
|
||||
for (const model of settingsModels) {
|
||||
if (seenModelIds.has(model.id)) {
|
||||
const existingIndex = models.findIndex((candidate) => candidate.id === model.id);
|
||||
if (existingIndex !== -1) {
|
||||
const existing = models[existingIndex];
|
||||
if (existing?.isSelectable === false) {
|
||||
models[existingIndex] = { ...existing, ...model, isSelectable: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seenModelIds.add(model.id);
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
@@ -149,8 +169,8 @@ const CLAUDE_PLACEHOLDER_MODEL_IDS = new Set(["<synthetic>"]);
|
||||
* (docs/custom-providers.md) among them. Manifest-only resolution would blank the model for
|
||||
* exactly those users.
|
||||
*
|
||||
* Note a `[1m]` suffix is preserved where it names its own manifest entry: a 1M-context variant
|
||||
* is a distinct model with a distinct context window, not a spelling of the 200K one.
|
||||
* A `[1m]` suffix is preserved where it names its own manifest entry. Models such as Fable 5
|
||||
* that only have a 1M entry normalize the retired suffixed spelling to the canonical ID.
|
||||
*
|
||||
* Returns null for placeholders and empty values, meaning "not observed".
|
||||
*/
|
||||
|
||||
@@ -57,10 +57,17 @@ const MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||
aliases: ["legacy-five-minute-stream"],
|
||||
label: "Five minute stream",
|
||||
description:
|
||||
"Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", isDefault: true },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "low",
|
||||
metadata: {
|
||||
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
||||
@@ -76,6 +83,22 @@ const MODELS: AgentModelDefinition[] = [
|
||||
intervalMs: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "legacy-five-minute-stream",
|
||||
label: "Legacy five minute stream",
|
||||
isSelectable: false,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", isDefault: true },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "low",
|
||||
metadata: {
|
||||
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "one-minute-stream",
|
||||
@@ -91,6 +114,12 @@ const MODELS: AgentModelDefinition[] = [
|
||||
id: "ten-second-stream",
|
||||
label: "Ten second stream",
|
||||
description: "Fast realistic stream for tests and smoke checks.",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", isDefault: true },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "low",
|
||||
metadata: {
|
||||
durationMs: 10_000,
|
||||
intervalMs: 5,
|
||||
|
||||
@@ -217,6 +217,38 @@ describe("ProviderCatalogSession", () => {
|
||||
expect(res?.payload.error).toBe("Provider codex is disabled");
|
||||
});
|
||||
|
||||
it("hides compatibility-only entries from list_provider_models", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
snapshot: {
|
||||
getSnapshot: () => [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "codex", id: "gpt-5.4", label: "GPT 5.4" },
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-legacy",
|
||||
label: "GPT 5.4 legacy",
|
||||
isSelectable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleListProviderModelsRequest({
|
||||
type: "list_provider_models_request",
|
||||
provider: "codex",
|
||||
requestId: "m-selectable",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "list_provider_models_response");
|
||||
expect(response?.payload.models?.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
it("preserves missing cwd as the semantic global snapshot for model list reads", async () => {
|
||||
const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]);
|
||||
const warmUpSnapshotForCwd = vi.fn(async () => {});
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
isGlobalProviderSnapshotKey,
|
||||
type ProviderSnapshotManager,
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
ProviderSnapshotEntry,
|
||||
import {
|
||||
filterSelectableAgentModels,
|
||||
type AgentFeature,
|
||||
type AgentProvider,
|
||||
type AgentSessionConfig,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
|
||||
@@ -202,7 +203,7 @@ export class ProviderCatalogSession {
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
provider: msg.provider,
|
||||
models: entry.models ?? [],
|
||||
models: filterSelectableAgentModels(entry.models),
|
||||
error: null,
|
||||
fetchedAt: entry.fetchedAt ?? fetchedAt,
|
||||
requestId: msg.requestId,
|
||||
|
||||
Reference in New Issue
Block a user