diff --git a/packages/app/src/runtime/server/global-sync/utils.test.ts b/packages/app/src/runtime/server/global-sync/utils.test.ts index 2a39fce84f0..617456e94a9 100644 --- a/packages/app/src/runtime/server/global-sync/utils.test.ts +++ b/packages/app/src/runtime/server/global-sync/utils.test.ts @@ -4,7 +4,7 @@ import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProvid describe("normalizeAgentList", () => { test("adapts current agents to the app agent shape", () => { - const result = normalizeAgentList([ + const agents: AgentListOutput["data"] = [ { id: "build", name: "Build", @@ -16,7 +16,9 @@ describe("normalizeAgentList", () => { system: "Build software", permissions: [{ action: "read", resource: "*", effect: "allow" }], }, - ] as AgentListOutput["data"]) + ] + + const result = normalizeAgentList(agents) expect(result).toEqual([ { @@ -40,37 +42,40 @@ describe("normalizeAgentList", () => { describe("normalizeProviderList", () => { test("groups current models into the app provider catalog", () => { - const result = normalizeProviderList( - [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], - [ - { - id: "gpt-5", - modelID: "gpt-5", - providerID: "openai", - name: "GPT-5", - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - variants: [{ id: "high" }], - time: { released: 1 }, - cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], - status: "active", - enabled: true, - limit: { context: 128_000, output: 8_192 }, - }, - { - id: "gpt-old", - modelID: "gpt-old", - providerID: "openai", - name: "GPT Old", - capabilities: { tools: false, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 0 }, - cost: [], - status: "deprecated", - enabled: true, - limit: { context: 1, output: 1 }, - }, - ] as ModelListOutput["data"], - ) + const providers: ProviderListOutput["data"] = [ + { id: "openai", name: "OpenAI", activation: "auto", package: "@ai-sdk/openai" }, + ] + + const models: ModelListOutput["data"] = [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] + + const result = normalizeProviderList(providers, models) expect(result.connected).toEqual(["openai"]) expect(result.default).toEqual({ openai: "gpt-5" }) @@ -78,19 +83,58 @@ describe("normalizeProviderList", () => { expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ id: "gpt-5", providerID: "openai", - capabilities: { toolcall: true, attachment: true }, + capabilities: { toolcall: true, attachment: true, reasoning: true }, cost: { input: 1, output: 2 }, variants: { high: {} }, }) }) + + test.each([ + ["effort variant", [{ id: "low" }], undefined, true], + ["toggle variant", [{ id: "none" }, { id: "thinking" }], undefined, true], + ["reasoningField without variants", [], { reasoningField: "reasoning_content" }, true], + ["no variants or reasoningField", [], undefined, false], + ["disabled-only none variant", [{ id: "none" }], undefined, false], + ] as const)("derives reasoning capability from %s", (_, variants, compatibility, expected) => { + const providers: ProviderListOutput["data"] = [ + { id: "opencode", name: "OpenCode", activation: "auto", package: "@opencode/ai/providers/openai-compatible" }, + ] + + const models: ModelListOutput["data"] = [ + { + id: "model", + modelID: "model", + providerID: "opencode", + name: "Model", + compatibility, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [...variants], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + ] + + const result = normalizeProviderList(providers, models) + + expect(result.all.get("opencode")?.models.model?.capabilities.reasoning).toBe(expected) + }) }) describe("normalizeProjectInfo", () => { test("keeps the project VCS backend", () => { - const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] } - expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git") - expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg") - expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined() + const project: Project = { + id: "prj", + canonical: "/repo", + time: { created: 1, updated: 1, active: 1 }, + sandboxes: [], + } + + expect(normalizeProjectInfo({ ...project, vcs: "git" }).vcs).toBe("git") + expect(normalizeProjectInfo({ ...project, vcs: "hg" }).vcs).toBe("hg") + expect(normalizeProjectInfo(project).vcs).toBeUndefined() }) }) diff --git a/packages/app/src/runtime/server/global-sync/utils.ts b/packages/app/src/runtime/server/global-sync/utils.ts index af36458c609..9a232c2530b 100644 --- a/packages/app/src/runtime/server/global-sync/utils.ts +++ b/packages/app/src/runtime/server/global-sync/utils.ts @@ -10,17 +10,22 @@ const providerCatalogs = new WeakMap< WeakMap >() -export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { - if (input.every((agent) => !("request" in agent))) return input as Agent[] +function isLegacyAgentList(input: AgentListOutput["data"] | Agent[]): input is Agent[] { + return input.every((agent) => !("request" in agent)) +} - return (input as AgentListOutput["data"]).map((agent) => ({ +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (isLegacyAgentList(input)) return input + + return input.map((agent) => ({ name: agent.id, description: agent.description, mode: agent.mode, hidden: agent.hidden, - temperature: - typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, - topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + temperature: Number.isFinite(agent.request.settings.temperature) + ? Number(agent.request.settings.temperature) + : undefined, + topP: Number.isFinite(agent.request.settings.topP) ? Number(agent.request.settings.topP) : undefined, color: agent.color, permission: agent.permissions.map((rule) => ({ permission: rule.action, @@ -79,7 +84,11 @@ export function normalizeProviderList( family: model.family, capabilities: { temperature: false, - reasoning: false, + // Always-on reasoning models may expose no selectable variants yet still stream reasoning via reasoningField; + // a disabled-only `none` variant does not count as reasoning support. + reasoning: + model.compatibility?.reasoningField !== undefined || + model.variants.some((variant) => variant.id !== "none"), attachment: model.capabilities.input.some((item) => item !== "text"), toolcall: model.capabilities.tools, input: {