diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index 7fba45fd6bec..680bc301d6cc 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -208,6 +208,190 @@ describe("acpRegistrySnapshotReadiness", () => { ]); }); + it("keeps Qoder reasoning on the model that advertised it", () => { + const mode = { + id: "_t3/session-mode", + label: "Mode", + type: "select" as const, + options: [ + { id: "default", label: "Default" }, + { id: "yolo", label: "YOLO" }, + ], + currentValue: "default", + }; + const flashReasoning = { + id: "reasoning_effort", + label: "Reasoning", + type: "select" as const, + role: "reasoning" as const, + currentValue: "max", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Extra High" }, + { id: "none", label: "None" }, + ], + }; + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "qoder" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "qoder", + version: "1.1.81", + distribution: "local", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [ + { id: "auto", name: "Auto", description: null }, + { id: "dfmodel", name: "DeepSeek-Flash", description: null }, + ], + currentModelId: "dfmodel", + configOptions: [mode, flashReasoning], + sessionManagement: noSessionManagement, + }, + slashCommands: [], + skills: [], + }, + }); + + expect( + snapshot.models.find((model) => model.slug === "auto")?.capabilities?.optionDescriptors, + ).toEqual([mode]); + expect( + snapshot.models.find((model) => model.slug === "dfmodel")?.capabilities?.optionDescriptors, + ).toEqual([mode, flashReasoning]); + + const switched = applyAcpRegistryLiveConfiguration( + snapshot, + { + models: [ + { id: "auto", name: "Auto", description: null }, + { id: "dfmodel", name: "DeepSeek-Flash", description: null }, + ], + currentModelId: "auto", + configOptions: [mode], + }, + [], + ); + expect( + switched.models.find((model) => model.slug === "auto")?.capabilities?.optionDescriptors, + ).toEqual([mode]); + expect( + switched.models.find((model) => model.slug === "dfmodel")?.capabilities?.optionDescriptors, + ).toEqual([mode, flashReasoning]); + + const reprobed = { + ...switched, + models: switched.models.map((model) => ({ + ...model, + capabilities: { optionDescriptors: [mode] }, + })), + }; + const afterProbe = applyAcpRegistryLiveConfiguration( + reprobed, + { + models: [ + { id: "auto", name: "Auto", description: null }, + { id: "dfmodel", name: "DeepSeek-Flash", description: null }, + ], + currentModelId: "auto", + configOptions: [mode], + }, + [], + switched.models, + ); + expect( + afterProbe.models.find((model) => model.slug === "dfmodel")?.capabilities?.optionDescriptors, + ).toEqual([mode, flashReasoning]); + + const nextReasoning = { + ...flashReasoning, + currentValue: "high", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + }; + const refreshed = applyAcpRegistryLiveConfiguration( + afterProbe, + { + models: [ + { id: "auto", name: "Auto", description: null }, + { id: "dfmodel", name: "DeepSeek-Flash", description: null }, + ], + currentModelId: "dfmodel", + configOptions: [mode, nextReasoning], + }, + [], + ); + expect( + refreshed.models.find((model) => model.slug === "dfmodel")?.capabilities?.optionDescriptors, + ).toEqual([mode, nextReasoning]); + expect( + refreshed.models.find((model) => model.slug === "auto")?.capabilities?.optionDescriptors, + ).toEqual([mode]); + }); + + it("does not give custom models another model's reasoning levels", () => { + const mode = { + id: "_t3/session-mode", + label: "Mode", + type: "select" as const, + options: [{ id: "default", label: "Default" }], + currentValue: "default", + }; + const flashReasoning = { + id: "reasoning_effort", + label: "Reasoning", + type: "select" as const, + role: "reasoning" as const, + currentValue: "max", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Extra High" }, + ], + }; + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "qoder", customModels: ["my-flash"] }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "qoder", + version: "1.1.81", + distribution: "local", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [{ id: "dfmodel", name: "DeepSeek-Flash", description: null }], + currentModelId: "dfmodel", + configOptions: [mode, flashReasoning], + sessionManagement: noSessionManagement, + }, + slashCommands: [], + skills: [], + }, + }); + + expect( + snapshot.models.find((model) => model.slug === "my-flash")?.capabilities?.optionDescriptors, + ).toEqual([mode]); + expect( + snapshot.models.find((model) => model.slug === "dfmodel")?.capabilities?.optionDescriptors, + ).toEqual([mode, flashReasoning]); + }); + it("projects discovery without claiming authentication when no login methods are advertised", () => { const snapshot = buildCheckedAcpRegistrySnapshot({ ...identity, diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index dd5a95cb8590..2a73e33a4ebe 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -4,6 +4,7 @@ import { resolveOfficialAcpRegistryIconUrl, TextGenerationError, type AcpRegistryOperationError, + type ProviderOptionDescriptor, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -63,6 +64,8 @@ const decodeSettings = Schema.decodeSync(AcpRegistrySettings); // Cache only successful authenticated probes so sign-in changes remain visible. const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000; const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +// Qoder advertises this id and changes the choice list with the model. +const PER_MODEL_REASONING_OPTION_ID = "reasoning_effort"; const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, packageName: null, @@ -86,17 +89,60 @@ const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { }; }; +function isPerModelReasoningDescriptor(descriptor: ProviderOptionDescriptor): boolean { + return descriptor.id === PER_MODEL_REASONING_OPTION_ID; +} + +function capabilitiesFromDescriptors( + descriptors: ReadonlyArray, +): ServerProviderModel["capabilities"] { + return descriptors.length === 0 + ? EMPTY_CAPABILITIES + : createModelCapabilities({ optionDescriptors: descriptors }); +} + +/** + * `reasoning_effort` belongs to the model that advertised it. Other options, such as session + * mode, stay shared. A later selection keeps a model's levels after the session moves on. + * Custom models were not in that advertisement, so they receive the shared options only. + */ +function descriptorsForDiscoveredModel( + modelId: string, + discovery: Pick, + previousModels: ReadonlyArray | undefined, +): ReadonlyArray { + const shared = discovery.configOptions; + if (discovery.currentModelId == null || modelId === discovery.currentModelId) { + return shared; + } + const sharedWithoutReasoning = shared.filter( + (descriptor) => !isPerModelReasoningDescriptor(descriptor), + ); + const kept = ( + previousModels?.find((model) => model.slug === modelId)?.capabilities?.optionDescriptors ?? [] + ).filter(isPerModelReasoningDescriptor); + return [...sharedWithoutReasoning, ...kept]; +} + function modelsFromDiscovery( discovery: | Pick | undefined, customModels: ReadonlyArray, + previousModels?: ReadonlyArray, ): ReadonlyArray { const discovered = discovery?.models ?? []; - const capabilities = - discovery === undefined || discovery.configOptions.length === 0 + const customModelCapabilities = capabilitiesFromDescriptors( + (discovery?.configOptions ?? []).filter( + (descriptor) => !isPerModelReasoningDescriptor(descriptor), + ), + ); + const capabilitiesFor = (modelId: string) => + discovery === undefined ? EMPTY_CAPABILITIES - : createModelCapabilities({ optionDescriptors: discovery.configOptions }); + : capabilitiesFromDescriptors( + descriptorsForDiscoveredModel(modelId, discovery, previousModels), + ); const builtInModels: ReadonlyArray = discovered.length === 0 ? [ @@ -105,7 +151,7 @@ function modelsFromDiscovery( name: "Default", isCustom: false, isDefault: true, - capabilities, + capabilities: capabilitiesFor("default"), }, ] : discovered.map((model) => ({ @@ -113,9 +159,9 @@ function modelsFromDiscovery( name: model.name, isCustom: false, ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}), - capabilities, + capabilities: capabilitiesFor(model.id), })); - return providerModelsFromSettings(builtInModels, customModels, capabilities); + return providerModelsFromSettings(builtInModels, customModels, customModelCapabilities); } function acpRegistrySnapshotReadiness( @@ -258,6 +304,7 @@ export function applyAcpRegistryLiveConfiguration( provider: ServerProvider, configuration: AcpRegistryLiveConfiguration, customModels: ReadonlyArray, + previousModels: ReadonlyArray = [], ): ServerProvider { const { message: _staleProbeMessage, ...snapshot } = provider; // session/load may omit models (Kimi returns `{}`); keep the probed list @@ -281,7 +328,12 @@ export function applyAcpRegistryLiveConfiguration( models: configuration.models.length === 0 && configuration.configOptions.length === 0 ? provider.models - : modelsFromDiscovery(discovery, customModels), + : modelsFromDiscovery(discovery, customModels, [ + ...provider.models.filter((model) => + model.capabilities?.optionDescriptors?.some(isPerModelReasoningDescriptor), + ), + ...previousModels, + ]), }; } @@ -550,6 +602,32 @@ export const AcpRegistryDriver: ProviderDriver>([]); + const applyRememberedConfiguration = Effect.fn( + "AcpRegistryDriver.applyRememberedConfiguration", + )(function* ( + provider: ServerProvider, + configuration: Option.Option, + ) { + const previousModels = yield* Ref.get(learnedModels); + const next = Option.match(configuration, { + onNone: () => provider, + onSome: (liveConfiguration) => + applyAcpRegistryLiveConfiguration( + provider, + liveConfiguration, + effectiveConfig.customModels, + previousModels, + ), + }); + yield* Ref.set(learnedModels, [ + ...new Map( + [...previousModels, ...next.models].map((model) => [model.slug, model]), + ).values(), + ]); + return next; + }); const withLiveRuntimeState = Effect.fn("AcpRegistryDriver.withLiveRuntimeState")(function* ( input: ServerProvider, ) { @@ -568,18 +646,13 @@ export const AcpRegistryDriver: ProviderDriver { + Effect.flatMap(({ commands, configuration, authAction }) => { const withCommands = applyAcpRegistryAvailableCommands(provider, commands); - const withConfiguration = Option.match(configuration, { - onNone: () => withCommands, - onSome: (liveConfiguration) => - applyAcpRegistryLiveConfiguration( - withCommands, - liveConfiguration, - effectiveConfig.customModels, - ), - }); - return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); + return applyRememberedConfiguration(withCommands, configuration).pipe( + Effect.map((withConfiguration) => + applyAcpRegistryUrlAuthAction(withConfiguration, authAction), + ), + ); }), ); }); @@ -707,12 +780,8 @@ export const AcpRegistryDriver: ProviderDriver - publishSnapshot( - applyAcpRegistryLiveConfiguration( - current, - configuration, - effectiveConfig.customModels, - ), + applyRememberedConfiguration(current, Option.some(configuration)).pipe( + Effect.flatMap(publishSnapshot), ), ), ), diff --git a/packages/provider-acp/src/server/sessionConfig.test.ts b/packages/provider-acp/src/server/sessionConfig.test.ts index ceb6ceb92783..80906255b5f0 100644 --- a/packages/provider-acp/src/server/sessionConfig.test.ts +++ b/packages/provider-acp/src/server/sessionConfig.test.ts @@ -43,7 +43,7 @@ describe("acpProviderOptionDescriptors", () => { expect(descriptors).toEqual([ { id: "reasoning_effort", - label: "Reasoning effort", + label: "Reasoning", type: "select", options: [ { id: "low", label: "Low" }, @@ -55,6 +55,106 @@ describe("acpProviderOptionDescriptors", () => { ]); }); + it("orders Qoder reasoning levels and badges High as the default", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "thought_level", + type: "select", + currentValue: "max", + options: [ + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + { value: "low", name: "Low" }, + { value: "none", name: "None" }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors).toEqual([ + { + id: "reasoning_effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Extra High" }, + { id: "none", label: "None" }, + ], + currentValue: "max", + role: "reasoning", + }, + ]); + }); + + it("does not badge High as the default for other thought levels", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "thinking_level", + name: "Thinking Level", + category: "thought_level", + type: "select", + currentValue: "low", + options: [ + { value: "high", name: "High" }, + { value: "low", name: "Low" }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors).toEqual([ + { + id: "thinking_level", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + currentValue: "low", + role: "reasoning", + }, + ]); + }); + + it("keeps Max distinct when the model also offers Extra High", () => { + const descriptors = acpProviderOptionDescriptors({ + configOptions: [ + { + id: "reasoning", + name: "Reasoning Effort", + category: "thought_level", + type: "select", + currentValue: "xhigh", + options: [ + { value: "max", name: "Max" }, + { value: "xhigh", name: "XHigh" }, + { value: "low", name: "low" }, + ], + }, + ], + modeState: undefined, + }); + + expect(descriptors[0]).toMatchObject({ + label: "Reasoning", + currentValue: "xhigh", + options: [ + { id: "low", label: "Low" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], + }); + }); + it("flattens grouped select choices and drops empty or duplicate values", () => { const descriptors = acpProviderOptionDescriptors({ configOptions: [ diff --git a/packages/provider-acp/src/server/sessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts index b79fdaebe245..0a0152a50fbc 100644 --- a/packages/provider-acp/src/server/sessionConfig.ts +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -13,6 +13,24 @@ const MAX_OPTION_CHOICES = 64; const MAX_TEXT_LENGTH = 256; const MAX_DESCRIPTION_LENGTH = 1_024; +// Low through None, then Off. Extra-high aliases sit with xhigh, ahead of max. +const THOUGHT_LEVEL_ORDER = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "extra-high", + "extra_high", + "max", + "none", + "off", +] as const; + +const THOUGHT_LEVEL_RANK = new Map( + THOUGHT_LEVEL_ORDER.map((id, index) => [id, index]), +); + const boundedText = (value: string | null | undefined, maximumLength: number): string => (value ?? "").trim().slice(0, maximumLength); @@ -52,6 +70,56 @@ function selectChoices( return choices; } +function thoughtLevelLabel(id: string, fallback: string, separateMax: boolean): string { + switch (id) { + case "minimal": + return "Minimal"; + case "low": + return "Low"; + case "medium": + return "Medium"; + case "high": + return "High"; + case "xhigh": + case "extra-high": + case "extra_high": + return "Extra High"; + case "max": + // One top bucket is Extra High. Max stays Max only when Extra High is also offered. + return separateMax ? "Max" : "Extra High"; + case "none": + return "None"; + case "off": + return "Off"; + default: + return fallback; + } +} + +/** + * Agents omit a default flag. Qoder's `reasoning_effort` column badges High as Default + * while the check mark stays on `currentValue`. Other thought levels must not get that + * flag: `isDefault` is also the value used when `currentValue` is absent. + */ +function presentThoughtLevelChoices( + choices: ReadonlyArray, + badgeHighAsDefault: boolean, +): ReadonlyArray { + const ids = new Set(choices.map((choice) => choice.id)); + const separateMax = ids.has("xhigh") || ids.has("extra-high") || ids.has("extra_high"); + return [...choices] + .sort( + (left, right) => + (THOUGHT_LEVEL_RANK.get(left.id) ?? THOUGHT_LEVEL_RANK.size) - + (THOUGHT_LEVEL_RANK.get(right.id) ?? THOUGHT_LEVEL_RANK.size), + ) + .map((choice) => ({ + ...choice, + label: thoughtLevelLabel(choice.id, choice.label, separateMax), + ...(badgeHighAsDefault && choice.id === "high" ? { isDefault: true } : {}), + })); +} + export function acpProviderOptionDescriptors(input: { readonly configOptions: ReadonlyArray | null | undefined; readonly modeState: AcpSessionModeState | undefined; @@ -91,18 +159,23 @@ export function acpProviderOptionDescriptors(input: { continue; } if (option.category === "mode") hasModeCategory = true; - if (option.category === "thought_level") { + const isThoughtLevel = option.category === "thought_level"; + if (isThoughtLevel) { mirroredModeChoiceSets.push(new Set(choices.map((choice) => choice.id))); } + const presentedChoices = isThoughtLevel + ? presentThoughtLevelChoices(choices, id === "reasoning_effort") + : choices; const currentValue = choiceId(option.currentValue); descriptors.push({ ...base, + ...(isThoughtLevel ? { label: "Reasoning" } : {}), type: "select", - options: choices, - ...(currentValue && choices.some((choice) => choice.id === currentValue) + options: presentedChoices, + ...(currentValue && presentedChoices.some((choice) => choice.id === currentValue) ? { currentValue } : {}), - ...(option.category === "thought_level" ? { role: "reasoning" as const } : {}), + ...(isThoughtLevel ? { role: "reasoning" as const } : {}), }); if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; }