From 29261cfc2662c24888376f844b9b0d45224f8cf2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Apr 2026 13:24:23 -0700 Subject: [PATCH 1/7] Refactor provider model selections to array options - Replace provider option objects with shared selection arrays - Update Claude, Codex, Cursor, and OpenCode routing and adapters - Refresh tests to use the new model selection shape --- .cursor/.gitignore | 1 + .../git/Layers/ClaudeTextGeneration.test.ts | 21 +- .../src/git/Layers/ClaudeTextGeneration.ts | 36 +- .../git/Layers/CodexTextGeneration.test.ts | 13 +- .../src/git/Layers/CodexTextGeneration.ts | 8 +- .../git/Layers/CursorTextGeneration.test.ts | 13 +- .../src/git/Layers/CursorTextGeneration.ts | 2 +- .../src/git/Layers/OpenCodeTextGeneration.ts | 11 +- .../src/git/Layers/RoutingTextGeneration.ts | 36 +- .../Layers/ProviderCommandReactor.test.ts | 127 +-- .../decider.projectScripts.test.ts | 25 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 111 +-- .../src/provider/Layers/ClaudeAdapter.ts | 65 +- .../src/provider/Layers/ClaudeProvider.ts | 207 +++-- .../src/provider/Layers/CodexAdapter.test.ts | 23 +- .../src/provider/Layers/CodexAdapter.ts | 17 +- .../src/provider/Layers/CodexProvider.ts | 61 +- .../src/provider/Layers/CursorAdapter.test.ts | 27 +- .../src/provider/Layers/CursorAdapter.ts | 6 +- .../provider/Layers/CursorProvider.test.ts | 195 ++--- .../src/provider/Layers/CursorProvider.ts | 156 +++- .../src/provider/Layers/OpenCodeAdapter.ts | 14 +- .../provider/Layers/OpenCodeProvider.test.ts | 14 +- .../src/provider/Layers/OpenCodeProvider.ts | 70 +- .../Layers/ProviderAdapterRegistry.ts | 13 +- .../provider/Layers/ProviderRegistry.test.ts | 107 ++- .../src/provider/Layers/ProviderRegistry.ts | 47 +- .../provider/Layers/ProviderService.test.ts | 22 +- .../src/provider/acp/CursorAcpSupport.test.ts | 10 +- .../src/provider/acp/CursorAcpSupport.ts | 6 +- .../src/provider/builtInProviderCatalog.ts | 49 ++ .../makeManagedServerProvider.test.ts | 28 +- apps/server/src/provider/opencodeRuntime.ts | 1 - .../src/provider/providerSnapshot.test.ts | 28 +- apps/server/src/provider/providerSnapshot.ts | 55 ++ .../src/provider/providerStatusCache.test.ts | 27 +- apps/server/src/serverSettings.test.ts | 75 +- apps/web/src/components/ChatView.browser.tsx | 128 +-- apps/web/src/components/ChatView.tsx | 24 +- apps/web/src/components/chat/ChatComposer.tsx | 20 +- .../CompactComposerControlsMenu.browser.tsx | 195 +++-- .../chat/ProviderModelPicker.browser.tsx | 258 +++--- .../components/chat/ProviderStatusBanner.tsx | 5 +- .../components/chat/TraitsPicker.browser.tsx | 821 ------------------ apps/web/src/components/chat/TraitsPicker.tsx | 500 ++++------- .../chat/composerProviderRegistry.test.tsx | 516 ----------- .../chat/composerProviderRegistry.tsx | 216 ----- .../chat/composerProviderState.test.tsx | 242 ++++++ .../components/chat/composerProviderState.tsx | 108 +++ .../components/settings/SettingsPanels.tsx | 28 +- apps/web/src/composerDraftStore.test.ts | 141 +-- apps/web/src/composerDraftStore.ts | 399 ++++----- apps/web/src/modelSelection.ts | 47 +- apps/web/src/providerModels.ts | 68 +- packages/contracts/src/model.ts | 106 +-- packages/contracts/src/orchestration.test.ts | 19 +- packages/contracts/src/orchestration.ts | 15 +- packages/contracts/src/provider.test.ts | 63 +- packages/contracts/src/server.ts | 3 + packages/contracts/src/settings.ts | 40 +- packages/shared/src/model.test.ts | 262 +++--- packages/shared/src/model.ts | 318 +++---- packages/shared/src/serverSettings.test.ts | 71 +- packages/shared/src/serverSettings.ts | 68 +- 64 files changed, 2585 insertions(+), 3823 deletions(-) create mode 100644 .cursor/.gitignore create mode 100644 apps/server/src/provider/builtInProviderCatalog.ts delete mode 100644 apps/web/src/components/chat/TraitsPicker.browser.tsx delete mode 100644 apps/web/src/components/chat/composerProviderRegistry.test.tsx delete mode 100644 apps/web/src/components/chat/composerProviderRegistry.tsx create mode 100644 apps/web/src/components/chat/composerProviderState.test.tsx create mode 100644 apps/web/src/components/chat/composerProviderState.tsx diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 00000000000..8bf7cc27a18 --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 08471346989..773f781eed1 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -199,12 +200,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - effort: "high", - }, + ...createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ]), }, }); @@ -235,12 +234,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { diffSummary: "1 file changed", diffPatch: "diff --git a/README.md b/README.md", modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, + ...createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), }, }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 97e18c3e789..e36d80b748e 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,10 +28,13 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; +import { getProviderOptionCurrentValue, getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { + getClaudeModelCapabilities, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -84,15 +87,24 @@ const makeClaudeTextGeneration = Effect.gen(function* () { modelSelection: ClaudeModelSelection; }): Effect.fn.Return { const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); - const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( - getClaudeModelCapabilities(modelSelection.model), - modelSelection.options, - ); + const caps = getClaudeModelCapabilities(modelSelection.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); + const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); + const rawEffortValue = getProviderOptionCurrentValue(findDescriptor("effort")); + const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : undefined; + const resolvedEffort = resolveClaudeEffort(caps, rawEffort); + const thinkingDescriptor = findDescriptor("thinking"); + const fastModeDescriptor = findDescriptor("fastMode"); + const thinking = + thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + const fastMode = + fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { - ...(typeof normalizedOptions?.thinking === "boolean" - ? { alwaysThinkingEnabled: normalizedOptions.thinking } - : {}), - ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), }; const claudeSettings = yield* Effect.map( @@ -111,7 +123,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { jsonSchemaStr, "--model", resolveClaudeApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(resolvedEffort ? ["--effort", resolvedEffort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", ], diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index a07505f025c..2adfcca8483 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Result } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -244,14 +245,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), }); }), ), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f15bfa1868..abadd5a4bb0 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -30,6 +30,7 @@ import { toJsonSchemaObject, } from "../Utils.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { getModelSelectionOptionValue } from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -155,7 +156,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const reasoningEffort = - modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + (getModelSelectionOptionValue(modelSelection, "reasoningEffort") as string | undefined) ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( codexSettings?.binaryPath || "codex", [ @@ -168,7 +170,9 @@ const makeCodexTextGeneration = Effect.gen(function* () { modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []), + ...(getModelSelectionOptionValue(modelSelection, "fastMode") === true + ? ["--config", `service_tier="fast"`] + : []), "--output-schema", schemaPath, "--output-last-message", diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index e7bce113474..b8d974fd94d 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -6,6 +6,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; import { ServerSettingsError } from "@t3tools/contracts"; @@ -135,13 +136,11 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { stagedPatch: "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", modelSelection: { - provider: "cursor", - model: "gpt-5.4", - options: { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }, + ...createModelSelection("cursor", "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), }, }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 24f066059c7..6b78728b953 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { yield* applyCursorAcpModelSelection({ runtime, model: modelSelection.model, - modelOptions: modelSelection.options, + selections: modelSelection.options, mapError: ({ cause, configId, step }) => mapCursorAcpError( operation, diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index fd28188d600..622a46dc281 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -7,6 +7,7 @@ import { type OpenCodeModelSelection, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionOptionValue } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -324,11 +325,13 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const result = await client.session.prompt({ sessionID: session.data.id, model: parsedModel, - ...(input.modelSelection.options?.agent - ? { agent: input.modelSelection.options.agent } + ...(typeof getModelSelectionOptionValue(input.modelSelection, "agent") === "string" + ? { agent: getModelSelectionOptionValue(input.modelSelection, "agent") as string } : {}), - ...(input.modelSelection.options?.variant - ? { variant: input.modelSelection.options.variant } + ...(typeof getModelSelectionOptionValue(input.modelSelection, "variant") === "string" + ? { + variant: getModelSelectionOptionValue(input.modelSelection, "variant") as string, + } : {}), parts: [{ type: "text", text: input.prompt }, ...fileParts], }); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 8f5c166d817..873d616f39a 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -11,11 +11,7 @@ */ import { Effect, Layer, Context } from "effect"; -import { - TextGeneration, - type TextGenerationProvider, - type TextGenerationShape, -} from "../Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; @@ -46,26 +42,22 @@ class OpenCodeTextGen extends Context.Service - provider === "claudeAgent" - ? claude - : provider === "opencode" - ? openCode - : provider === "cursor" - ? cursor - : codex; + const byProvider = { + codex: yield* CodexTextGen, + claudeAgent: yield* ClaudeTextGen, + cursor: yield* CursorTextGen, + opencode: yield* OpenCodeTextGen, + }; return { generateCommitMessage: (input) => - route(input.modelSelection.provider).generateCommitMessage(input), - generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), - generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), + byProvider[input.modelSelection.provider].generateCommitMessage(input), + generatePrContent: (input) => + byProvider[input.modelSelection.provider].generatePrContent(input), + generateBranchName: (input) => + byProvider[input.modelSelection.provider].generateBranchName(input), + generateThreadTitle: (input) => + byProvider[input.modelSelection.provider].generateThreadTitle(input), } satisfies TextGenerationShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dfdfab926f8..a52ed5856e8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, CommandId, @@ -567,14 +568,10 @@ describe("ProviderCommandReactor", () => { text: "hello fast mode", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -584,25 +581,17 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }); }); @@ -623,13 +612,9 @@ describe("ProviderCommandReactor", () => { text: "hello with effort", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -639,23 +624,15 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), }); }); @@ -676,13 +653,9 @@ describe("ProviderCommandReactor", () => { text: "hello with fast mode", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -692,23 +665,15 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "fastMode", value: true }, + ]), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "fastMode", value: true }, + ]), }); }); @@ -916,13 +881,9 @@ describe("ProviderCommandReactor", () => { text: "first claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "medium", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "medium" }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -943,13 +904,9 @@ describe("ProviderCommandReactor", () => { text: "second claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -960,13 +917,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ resumeCursor: { opaque: "resume-1" }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), }); }); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a85e21c53f3..1feabb0ed85 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -6,6 +6,7 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; @@ -162,14 +163,10 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -191,14 +188,10 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), runtimeMode: "approval-required", }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 79c66bdfcf8..07ea11f0d63 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -17,6 +17,7 @@ import { type RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Layer, Random, Stream } from "effect"; @@ -333,13 +334,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), runtimeMode: "full-access", }); @@ -380,13 +377,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-7", - options: { - effort: "xhigh", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [ + { id: "effort", value: "xhigh" }, + ]), runtimeMode: "full-access", }); @@ -405,13 +398,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), runtimeMode: "full-access", }); @@ -430,13 +419,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "high", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "effort", value: "high" }, + ]), runtimeMode: "full-access", }); @@ -455,13 +440,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "thinking", value: false }, + ]), runtimeMode: "full-access", }); @@ -482,13 +463,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - thinking: false, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "thinking", value: false }, + ]), runtimeMode: "full-access", }); @@ -507,13 +484,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -534,13 +507,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -559,13 +528,9 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "ultrathink" }, + ]), runtimeMode: "full-access", }); @@ -573,13 +538,9 @@ describe("ClaudeAdapterLive", () => { threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "ultrathink" }, + ]), }); const createInput = harness.getLastCreateQueryInput(); @@ -2794,13 +2755,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - contextWindow: "1m", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "contextWindow", value: "1m" }, + ]), attachments: [], }); yield* adapter.sendTurn({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81980acb9b1..6c21c0cef18 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,9 +40,13 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeAgentEffort, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + getProviderOptionDescriptors, + getModelSelectionOptionValue, + trimOrNull, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -61,7 +65,11 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; +import { + getClaudeModelCapabilities, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -211,9 +219,7 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeAgentEffort( - effort: ClaudeAgentEffort | null | undefined, -): ClaudeSdkEffort | null { +function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { if (!effort) { return null; } @@ -223,7 +229,7 @@ function getEffectiveClaudeAgentEffort( if (effort === "xhigh") { return "max"; } - return effort; + return effort as ClaudeSdkEffort; } function isClaudeInterruptedMessage(message: string): boolean { @@ -554,17 +560,34 @@ const CLAUDE_SETTING_SOURCES = [ ] as const satisfies ReadonlyArray; function buildPromptText(input: ProviderSendTurnInput): string { - const rawEffort = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + const rawEffortValue = + input.modelSelection?.provider === "claudeAgent" + ? getModelSelectionOptionValue(input.modelSelection, "effort") + : null; + const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : null; const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. + // Normal Claude effort resolution strips prompt-injected values back to the model default, + // so prompt formatting must look at the raw selection value directly. const trimmedEffort = trimOrNull(rawEffort); + const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "effort" || + descriptor.id === "reasoningEffort" || + descriptor.id === "reasoning" || + descriptor.id === "variant") && + (descriptor.promptInjectedValues?.length ?? 0) > 0, + ); const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + trimmedEffort && + promptInjectedDescriptor?.type === "select" && + promptInjectedDescriptor.promptInjectedValues?.includes(trimmedEffort) + ? trimmedEffort + : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -2824,13 +2847,23 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); + const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeAgentEffort | null; - const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; + const rawEffort = getModelSelectionOptionValue(modelSelection, "effort"); + const effort = + resolveClaudeEffort(caps, typeof rawEffort === "string" ? rawEffort : undefined) ?? null; + const fastModeSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + const thinkingSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); + const fastMode = + getModelSelectionOptionValue(modelSelection, "fastMode") === true && fastModeSupported; const thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking + typeof getModelSelectionOptionValue(modelSelection, "thinking") === "boolean" && + thinkingSupported + ? (getModelSelectionOptionValue(modelSelection, "thinking") as boolean) : undefined; const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 7c8a4c27a6e..3ff5407af37 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -11,6 +11,12 @@ import type { import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { + createModelCapabilities, + getModelSelectionOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, @@ -18,6 +24,8 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, detailFromResult, @@ -34,108 +42,143 @@ import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; -const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); const PROVIDER = "claudeAgent" as const; +const CLAUDE_PRESENTATION = { + displayName: "Claude", + showInteractionModeToggle: true, +} as const; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-7", name: "Claude Opus 4.7", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-5", name: "Claude Opus 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + }), }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildBooleanOptionDescriptor({ + id: "thinking", + label: "Thinking", + }), + ], + }), }, ]; @@ -165,8 +208,21 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } +export function resolveClaudeEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); + const value = getProviderOptionCurrentValue(effortDescriptor); + return typeof value === "string" ? value : undefined; +} + export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (modelSelection.options?.contextWindow) { + switch (getModelSelectionOptionValue(modelSelection, "contextWindow")) { case "1m": return `${modelSelection.model}[1m]`; default: @@ -578,6 +634,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models: allModels, @@ -600,6 +657,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = versionProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -618,6 +676,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(versionProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -638,6 +697,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const detail = detailFromResult(version); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -702,6 +762,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = authProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -722,6 +783,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(authProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -740,6 +802,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -773,6 +836,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models, @@ -788,6 +852,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: true, checkedAt, models, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 03d41559345..ad6fddab1ca 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -14,6 +14,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; @@ -242,13 +243,9 @@ validationLayer("CodexAdapterLive validation", (it) => { yield* adapter.startSession({ provider: "codex", threadId: asThreadId("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -309,14 +306,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), attachments: [], }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0111cd013ce..cb3f8c464c9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,6 +26,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { getModelSelectionOptionValue } from "@t3tools/shared/model"; + import { ProviderAdapterRequestError, ProviderAdapterProcessError, @@ -1372,7 +1374,8 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode + ...(input.modelSelection?.provider === "codex" && + getModelSelectionOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), }; @@ -1492,10 +1495,16 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(input.modelSelection?.provider === "codex" && - input.modelSelection.options?.reasoningEffort !== undefined - ? { effort: input.modelSelection.options.reasoningEffort } + typeof getModelSelectionOptionValue(input.modelSelection, "reasoningEffort") === "string" + ? { + effort: getModelSelectionOptionValue( + input.modelSelection, + "reasoningEffort", + ) as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode + ...(input.modelSelection?.provider === "codex" && + getModelSelectionOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index cea768266d8..0a9d529795b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -25,6 +25,8 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { buildServerProvider } from "../providerSnapshot.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; @@ -33,6 +35,10 @@ import packageJson from "../../../package.json" with { type: "json" }; const PROVIDER = "codex" as const; const PROVIDER_PROBE_TIMEOUT_MS = 8_000; +const CODEX_PRESENTATION = { + displayName: "Codex", + showInteractionModeToggle: true, +} as const; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; @@ -84,17 +90,44 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { - return { - reasoningEffortLevels: model.supportedReasoningEfforts.map(({ reasoningEffort }) => ({ - value: reasoningEffort, - label: REASONING_EFFORT_LABELS[reasoningEffort], - ...(reasoningEffort === model.defaultReasoningEffort ? { isDefault: true } : {}), - })), - supportsFastMode: (model.additionalSpeedTiers ?? []).includes("fast"), - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; + const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => + reasoningEffort === model.defaultReasoningEffort + ? { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + isDefault: true, + } + : { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + }, + ); + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const supportsFastMode = (model.additionalSpeedTiers ?? []).includes("fast"); + return createModelCapabilities({ + optionDescriptors: [ + ...(reasoningOptions.length > 0 + ? [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }, + ] + : []), + ...(supportsFastMode + ? [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean" as const, + }, + ] + : []), + ], + }); } const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { @@ -289,6 +322,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models, @@ -305,6 +339,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: true, checkedAt, models, @@ -372,6 +407,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models: emptyModels, @@ -398,6 +434,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, @@ -417,6 +454,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Option.isNone(probeResult.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, @@ -436,6 +474,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: snapshot.models, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index e6bbd7569a4..1f619d49f1f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; @@ -357,15 +358,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { providers: { cursor: { binaryPath: wrapperPath } }, }); - const modelSelection = { - provider: "cursor" as const, - model: "gpt-5.4", - options: { - reasoning: "xhigh" as const, - contextWindow: "1m", - fastMode: true, - }, - }; + const modelSelection = createModelSelection("cursor", "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ]); yield* adapter.startSession({ threadId, @@ -1092,7 +1089,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "second turn after switching model", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: true }, + ]), }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); @@ -1147,14 +1146,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "first turn with fast mode", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: true }, + ]), }); yield* adapter.sendTurn({ threadId, input: "second turn without fast mode", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: false }, + ]), }); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index b09e0356bfb..5e12bfbefe2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,7 +7,7 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, - type CursorModelOptions, + type ProviderOptionSelection, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, @@ -222,7 +222,7 @@ function applyRequestedSessionConfiguration(input: { readonly modelSelection: | { readonly model: string; - readonly options?: CursorModelOptions | null | undefined; + readonly options?: ReadonlyArray | null | undefined; } | undefined; readonly mapError: (context: { @@ -235,7 +235,7 @@ function applyRequestedSessionConfiguration(input: { yield* applyCursorAcpModelSelection({ runtime: input.runtime, model: input.modelSelection.model, - modelOptions: input.modelSelection.options, + selections: input.modelSelection.options, mapError: ({ cause }) => input.mapError({ cause, diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index be90e3c8569..c0ac5d20b0c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -8,6 +8,7 @@ import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { buildCursorProviderSnapshot, @@ -27,6 +28,31 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + async function makeMockAgentWrapper(extraEnv?: Record) { const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); const wrapperPath = path.join(dir, "fake-agent.sh"); @@ -239,13 +265,7 @@ const baseCursorSettings: CursorSettings = { customModels: [], }; -const emptyCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} as const; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -309,52 +329,57 @@ describe("buildCursorProviderSnapshot", () => { describe("buildCursorCapabilitiesFromConfigOptions", () => { it("derives model capabilities from parameterized Cursor ACP config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272K", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "272k", label: "272K", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + booleanDescriptor("fastMode", "Fast", false), + ], + }), + ); }); it("detects boolean thinking toggles from model_config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); it("prefers the newer model_option effort control over legacy thought_level", () => { expect( buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), - ).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "max", label: "Max", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Effort", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", true), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); }); @@ -365,73 +390,39 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { slug: "default", name: "Auto", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), }, { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-sonnet-4-6", name: "Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "gpt-5.3-codex-spark", name: "Codex 5.3 Spark", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ]); }); @@ -645,11 +636,11 @@ describe("resolveCursorAcpBaseModelId", () => { describe("resolveCursorAcpConfigUpdates", () => { it("maps Cursor model options onto separate ACP config option updates", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), ).toEqual([ { configId: "reasoning", value: "extra-high" }, { configId: "context", value: "1m" }, @@ -659,26 +650,26 @@ describe("resolveCursorAcpConfigUpdates", () => { it("maps boolean thinking toggles when the model exposes them separately", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [ + { id: "thinking", value: false }, + ]), ).toEqual([{ configId: "thinking", value: false }]); }); it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - fastMode: false, - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "fastMode", value: false }, + ]), ).toEqual([{ configId: "fast", value: "false" }]); }); it("writes Cursor effort changes through the newer model_option config when available", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { - reasoning: "max", - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, [ + { id: "reasoning", value: "max" }, + { id: "thinking", value: false }, + ]), ).toEqual([ { configId: "effort", value: "max" }, { configId: "thinking", value: "false" }, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 70d5656b3ec..e07990a2fd0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -3,9 +3,9 @@ import * as nodeOs from "node:os"; import * as nodePath from "node:path"; import type { - CursorModelOptions, CursorSettings, ModelCapabilities, + ProviderOptionSelection, ServerProvider, ServerProviderAuth, ServerProviderModel, @@ -15,8 +15,11 @@ import type { import type * as EffectAcpSchema from "effect-acp/schema"; import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, collectStreamAsString, isCommandMissingCause, @@ -29,13 +32,14 @@ import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "cursor" as const; -const EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const CURSOR_PRESENTATION = { + displayName: "Cursor", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; @@ -55,6 +59,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models, @@ -70,6 +75,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: true, checkedAt, models, @@ -198,6 +204,28 @@ function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): return values.has("true") && values.has("false"); } +function getBooleanCurrentValue( + option: EffectAcpSchema.SessionConfigOption | undefined, +): boolean | undefined { + if (!option) { + return undefined; + } + if (option.type === "boolean") { + return option.currentValue; + } + if (option.type !== "select") { + return undefined; + } + const normalized = option.currentValue?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return undefined; +} + export function buildCursorCapabilitiesFromConfigOptions( configOptions: ReadonlyArray | null | undefined, ): ModelCapabilities { @@ -251,14 +279,60 @@ export function buildCursorCapabilitiesFromConfigOptions( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); + const fastCurrentValue = getBooleanCurrentValue(fastOption); + const thinkingCurrentValue = getBooleanCurrentValue(thinkingOption); + const optionDescriptors = [ + ...(reasoningEffortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []), + ...(contextWindowOptions.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: contextOption?.name?.trim() || "Context Window", + options: contextWindowOptions, + }), + ] + : []), + ...(fastOption && isBooleanLikeConfigOption(fastOption) + ? [ + typeof fastCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + currentValue: fastCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + }), + ] + : []), + ...(thinkingOption && isBooleanLikeConfigOption(thinkingOption) + ? [ + typeof thinkingCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + currentValue: thinkingCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + }), + ] + : []), + ]; - return { - reasoningEffortLevels, - supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, - supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, - contextWindowOptions, - promptInjectedEffortLevels: [], - }; + return createModelCapabilities({ + optionDescriptors, + }); } function buildCursorDiscoveredModels( @@ -282,13 +356,7 @@ function buildCursorDiscoveredModels( } function hasCursorModelCapabilities(model: Pick): boolean { - return ( - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 - ); + return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; } export function buildCursorDiscoveredModelsFromConfigOptions( @@ -385,9 +453,25 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; } +function getStringSelection( + selections: ReadonlyArray | null | undefined, + id: string, +): string | undefined { + const value = selections?.find((selection) => selection.id === id)?.value; + return typeof value === "string" ? value : undefined; +} + +function getBooleanSelection( + selections: ReadonlyArray | null | undefined, + id: string, +): boolean | undefined { + const value = selections?.find((selection) => selection.id === id)?.value; + return typeof value === "boolean" ? value : undefined; +} + export function resolveCursorAcpConfigUpdates( configOptions: ReadonlyArray | null | undefined, - modelOptions: CursorModelOptions | null | undefined, + selections: ReadonlyArray | null | undefined, ): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { if (!configOptions || configOptions.length === 0) { return []; @@ -396,7 +480,9 @@ export function resolveCursorAcpConfigUpdates( const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; const reasoningOption = findCursorEffortConfigOption(configOptions); - const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); + const requestedReasoning = normalizeCursorReasoningValue( + getStringSelection(selections, "reasoning"), + ); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { const normalizedValue = normalizeCursorReasoningValue(option.value); @@ -411,14 +497,15 @@ export function resolveCursorAcpConfigUpdates( const contextOption = configOptions.find( (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - if (contextOption && modelOptions?.contextWindow) { + const requestedContextWindow = getStringSelection(selections, "contextWindow"); + if (contextOption && requestedContextWindow) { const value = findCursorSelectOptionValue( contextOption, (option) => normalizeCursorConfigOptionToken(option.value) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow) || + normalizeCursorConfigOptionToken(requestedContextWindow) || normalizeCursorConfigOptionToken(option.name) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow), + normalizeCursorConfigOptionToken(requestedContextWindow), ); if (value) { updates.push({ configId: contextOption.id, value }); @@ -428,8 +515,9 @@ export function resolveCursorAcpConfigUpdates( const fastOption = configOptions.find( (option) => option.category === "model_config" && isCursorFastConfigOption(option), ); - if (fastOption && typeof modelOptions?.fastMode === "boolean") { - const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); + const requestedFastMode = getBooleanSelection(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); if (value !== undefined) { updates.push({ configId: fastOption.id, value }); } @@ -438,8 +526,9 @@ export function resolveCursorAcpConfigUpdates( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); - if (thinkingOption && typeof modelOptions?.thinking === "boolean") { - const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); + const requestedThinking = getBooleanSelection(selections, "thinking"); + if (thinkingOption && typeof requestedThinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); if (value !== undefined) { updates.push({ configId: thinkingOption.id, value }); } @@ -610,6 +699,7 @@ export function buildCursorProviderSnapshot(input: { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: input.cursorSettings.enabled, checkedAt: input.checkedAt, models: providerModelsFromSettings( @@ -962,6 +1052,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models: fallbackModels, @@ -985,6 +1076,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( const error = aboutProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, @@ -1003,6 +1095,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (Option.isNone(aboutProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, @@ -1025,6 +1118,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (parameterizedModelPickerUnsupportedMessage) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5081495dcb4..7719e660dd5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import { getModelSelectionOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1146,16 +1147,19 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const agent = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.agent + ? getModelSelectionOptionValue(input.modelSelection, "agent") : undefined; const variant = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.variant + ? getModelSelectionOptionValue(input.modelSelection, "variant") : undefined; + const selectedAgent = typeof agent === "string" ? agent : undefined; + const selectedVariant = typeof variant === "string" ? variant : undefined; context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; + context.activeAgent = + selectedAgent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = selectedVariant; updateProviderSession( context, { @@ -1171,7 +1175,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { type: "turn.started", payload: { model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), + ...(selectedVariant ? { effort: selectedVariant } : {}), }, }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index ffce7084342..0a9d3705640 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -145,14 +145,16 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); assert.ok(model); - assert.equal( - model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value, - "medium", + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", ); - assert.equal( - model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value, - "build", + assert.ok(variantDescriptor && variantDescriptor.type === "select"); + assert.equal(variantDescriptor.options.find((option) => option.isDefault)?.id, "medium"); + const agentDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); + assert.ok(agentDescriptor && agentDescriptor.type === "select"); + assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 5e51eae0282..ac72ad220e2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -6,6 +6,8 @@ import type { } from "@t3tools/contracts"; import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; +import { createModelCapabilities } from "@t3tools/shared/model"; + import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -23,6 +25,10 @@ import { import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = "opencode" as const; +const OPENCODE_PRESENTATION = { + displayName: "OpenCode", + showInteractionModeToggle: false, +} as const; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause: unknown; @@ -156,13 +162,9 @@ function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); function openCodeCapabilitiesForModel(input: { readonly providerID: string; @@ -171,27 +173,46 @@ function openCodeCapabilitiesForModel(input: { }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) => - Object.assign( - { value, label: titleCaseSlug(value) }, - defaultVariant === value ? { isDefault: true } : {}, - ), + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) => - Object.assign( - { value: agent.name, label: titleCaseSlug(agent.name) }, - defaultAgent === agent.name ? { isDefault: true } : {}, - ), + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, ); - return { - ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ...(variantOptions.length > 0 ? { variantOptions } : {}), - ...(agentOptions.length > 0 ? { agentOptions } : {}), - }; + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Variant", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); } function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { @@ -233,6 +254,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server if (!openCodeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models, @@ -251,6 +273,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, @@ -287,6 +310,7 @@ export const OpenCodeProviderLive = Layer.effect( }); return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: input.settings.enabled, checkedAt, models: providerModelsFromSettings( @@ -308,6 +332,7 @@ export const OpenCodeProviderLive = Layer.effect( if (!input.settings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models: providerModelsFromSettings( @@ -395,6 +420,7 @@ export const OpenCodeProviderLive = Layer.effect( const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 22fc0b7fda5..dabe5b8581e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -19,6 +19,7 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -31,12 +32,12 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [ - yield* CodexAdapter, - yield* ClaudeAdapter, - yield* OpenCodeAdapter, - ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []), - ]; + : createBuiltInAdapterList({ + codex: yield* CodexAdapter, + claudeAgent: yield* ClaudeAdapter, + opencode: yield* OpenCodeAdapter, + ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}), + }); const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 2d6855f914b..3185a69011b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -11,6 +11,7 @@ import { import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { deepMerge } from "@t3tools/shared/Struct"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; @@ -29,6 +30,44 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + +const fakeOpenCodeSnapshot: ServerProvider = { + provider: "opencode", + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode test stub", +}; + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -88,16 +127,15 @@ function failingSpawnerLayer(description: string) { ); } -const codexModelCapabilities = { - reasoningEffortLevels: [ - { value: "high", label: "High", isDefault: true }, - { value: "low", label: "Low" }, +const codexModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + { id: "low", label: "Low" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} satisfies NonNullable; +}) satisfies NonNullable; function makeCodexProbeSnapshot( input: Partial = {}, @@ -330,13 +368,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], slashCommands: [], @@ -367,13 +407,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], slashCommands: [], @@ -387,13 +429,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), }, ], } satisfies ServerProvider; @@ -603,9 +641,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", ); } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); assert.deepStrictEqual( - opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), - { value: "xhigh", label: "Extra High", isDefault: true }, + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 3f83419a389..ddd1701a94f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -25,13 +25,8 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; - -type ProviderSnapshotSource = { - readonly provider: ProviderKind; - readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; - readonly streamChanges: Stream.Stream; -}; +import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts"; +import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -41,11 +36,7 @@ const loadProviders = ( }); const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0; + (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const mergeProviderModels = ( previousModels: ReadonlyArray, @@ -98,32 +89,12 @@ const ProviderRegistryLiveBase = Layer.effect( const cursorProvider = yield* CursorProvider; - const providerSources = [ - { - provider: "codex", - getSnapshot: codexProvider.getSnapshot, - refresh: codexProvider.refresh, - streamChanges: codexProvider.streamChanges, - }, - { - provider: "claudeAgent", - getSnapshot: claudeProvider.getSnapshot, - refresh: claudeProvider.refresh, - streamChanges: claudeProvider.streamChanges, - }, - { - provider: "opencode", - getSnapshot: openCodeProvider.getSnapshot, - refresh: openCodeProvider.refresh, - streamChanges: openCodeProvider.streamChanges, - }, - { - provider: "cursor", - getSnapshot: cursorProvider.getSnapshot, - refresh: cursorProvider.refresh, - streamChanges: cursorProvider.streamChanges, - }, - ] satisfies ReadonlyArray; + const providerSources = createBuiltInProviderSources({ + codex: codexProvider, + claudeAgent: claudeProvider, + opencode: openCodeProvider, + cursor: cursorProvider, + }) satisfies ReadonlyArray; const activeProviders = PROVIDER_CACHE_IDS; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 089ae9da990..d7b8c1cb38a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -17,6 +17,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, vi } from "@effect/vitest"; import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; @@ -860,13 +861,9 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: "claudeAgent", threadId: asThreadId("thread-claude-send-turn"), cwd: "/tmp/project-claude-send-turn", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), runtimeMode: "full-access", }); @@ -893,13 +890,10 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); - assert.deepEqual(startPayload.modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }); + assert.deepEqual( + startPayload.modelSelection, + createModelSelection("claudeAgent", "claude-opus-4-6", [{ id: "effort", value: "max" }]), + ); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 94de569b2b2..30941acbfd5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -99,11 +99,11 @@ describe("applyCursorAcpModelSelection", () => { applyCursorAcpModelSelection({ runtime, model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", - modelOptions: { - reasoning: "xhigh", - contextWindow: "1m", - fastMode: true, - }, + selections: [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], mapError: ({ step, configId, cause }) => new Error( step === "set-config-option" diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 72b9af394b3..25e342f0d04 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,4 @@ -import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts"; +import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; @@ -76,7 +76,7 @@ interface CursorAcpModelSelectionRuntime { export function applyCursorAcpModelSelection(input: { readonly runtime: CursorAcpModelSelectionRuntime; readonly model: string | null | undefined; - readonly modelOptions: CursorModelOptions | null | undefined; + readonly selections: ReadonlyArray | null | undefined; readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; }): Effect.Effect { return Effect.gen(function* () { @@ -91,7 +91,7 @@ export function applyCursorAcpModelSelection(input: { const configUpdates = resolveCursorAcpConfigUpdates( yield* input.runtime.getConfigOptions, - input.modelOptions, + input.selections, ); for (const update of configUpdates) { yield* input.runtime.setConfigOption(update.configId, update.value).pipe( diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts new file mode 100644 index 00000000000..1fd10a85d9d --- /dev/null +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -0,0 +1,49 @@ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import type { Stream } from "effect"; +import type { ProviderAdapterError } from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +export type ProviderSnapshotSource = { + readonly provider: ProviderKind; + readonly getSnapshot: ServerProviderShape["getSnapshot"]; + readonly refresh: ServerProviderShape["refresh"]; + readonly streamChanges: Stream.Stream; +}; + +type BuiltInProviderServiceMap = Record; +type BuiltInAdapterMap = { + readonly codex: ProviderAdapterShape; + readonly claudeAgent: ProviderAdapterShape; + readonly opencode: ProviderAdapterShape; + readonly cursor?: ProviderAdapterShape; +}; + +export const BUILT_IN_PROVIDER_ORDER = [ + "codex", + "claudeAgent", + "opencode", + "cursor", +] as const satisfies ReadonlyArray; + +export function createBuiltInProviderSources( + services: BuiltInProviderServiceMap, +): ReadonlyArray { + return BUILT_IN_PROVIDER_ORDER.map((provider) => ({ + provider, + getSnapshot: services[provider].getSnapshot, + refresh: services[provider].refresh, + streamChanges: services[provider].streamChanges, + })); +} + +export function createBuiltInAdapterList( + adapters: BuiltInAdapterMap, +): ReadonlyArray> { + return [ + adapters.codex, + adapters.claudeAgent, + adapters.opencode, + ...(adapters.cursor ? [adapters.cursor] : []), + ]; +} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 31fe73a467e..5d50d12e372 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,9 +1,21 @@ import { describe, it, assert } from "@effect/vitest"; import type { ServerProvider } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const fastModeCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], +}); + interface TestSettings { readonly enabled: boolean; } @@ -43,13 +55,7 @@ const enrichedSnapshot: ServerProvider = { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: fastModeCapabilities, }, ], }; @@ -68,13 +74,7 @@ const enrichedSnapshotSecond: ServerProvider = { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], }; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 9f10738dbfb..41ec5102c3c 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -36,7 +36,6 @@ import { NetService } from "@t3tools/shared/Net"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; const DEFAULT_HOSTNAME = "127.0.0.1"; - export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 0a0d31ccb59..f990afb2d61 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,17 +1,27 @@ import { describe, expect, it } from "vitest"; import type { ModelCapabilities } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; -const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], - agentOptions: [{ value: "build", label: "Build", isDefault: true }], -}; +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build", isDefault: true }], + currentValue: "build", + }, + ], +}); describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 068b7c11578..50f6318b419 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -28,6 +28,12 @@ export interface ProviderProbeResult { readonly message?: string; } +export interface ServerProviderPresentation { + readonly displayName: string; + readonly badgeLabel?: string; + readonly showInteractionModeToggle?: boolean; +} + export function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -127,8 +133,52 @@ export function providerModelsFromSettings( return [...resolvedBuiltInModels, ...customEntries]; } +export function buildSelectOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly options: + | ReadonlyArray<{ value: string; label: string; isDefault?: boolean | undefined }> + | undefined; + readonly description?: string; + readonly promptInjectedValues?: ReadonlyArray; +}) { + const options = (input.options ?? []).map((option) => + option.isDefault + ? { id: option.value, label: option.label, isDefault: true } + : { id: option.value, label: option.label }, + ); + const currentValue = options.find((option) => option.isDefault)?.id; + return { + id: input.id, + label: input.label, + type: "select" as const, + options, + ...(currentValue ? { currentValue } : {}), + ...(input.description ? { description: input.description } : {}), + ...(input.promptInjectedValues && input.promptInjectedValues.length > 0 + ? { promptInjectedValues: [...input.promptInjectedValues] } + : {}), + }; +} + +export function buildBooleanOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly currentValue?: boolean; + readonly description?: string; +}) { + return { + id: input.id, + label: input.label, + type: "boolean" as const, + ...(input.description ? { description: input.description } : {}), + ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}), + }; +} + export function buildServerProvider(input: { provider: ServerProvider["provider"]; + presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; models: ReadonlyArray; @@ -138,6 +188,11 @@ export function buildServerProvider(input: { }): ServerProvider { return { provider: input.provider, + displayName: input.presentation.displayName, + ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), + ...(typeof input.presentation.showInteractionModeToggle === "boolean" + ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index b0cb5bc663c..5a81341f6d5 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ServerProvider } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; @@ -10,6 +11,8 @@ import { writeProviderStatusCache, } from "./providerStatusCache.ts"; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); + const makeProvider = ( provider: ServerProvider["provider"], overrides?: Partial, @@ -81,13 +84,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Cached message", @@ -106,13 +103,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Pending refresh", @@ -131,13 +122,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], installed: cachedCodex.installed, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 655ede9441f..d2b4eedf151 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; import { ServerConfig } from "./config.ts"; @@ -28,16 +29,12 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual( decodePatch({ textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }), { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }, ); @@ -62,10 +59,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + "codex", + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); @@ -76,9 +77,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }); @@ -94,14 +93,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: ["claude-custom"], launchArgs: "", }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: false, - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ]), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -114,9 +112,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "high", - }, + options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "high" }, + ]).options!, }, }); @@ -126,19 +124,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: "gpt-5.4", - options: { - reasoningEffort: "high", - }, + options: createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]).options!, }, }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "high", - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection("codex", "gpt-5.4", [{ id: "reasoningEffort", value: "high" }]), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -150,10 +145,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + "codex", + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6857a51b519..5ff6a7887f7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -18,6 +18,7 @@ import { DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -3777,14 +3778,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, + codex: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ]), }, stickyActiveProvider: "codex", }); @@ -3812,13 +3809,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + codex: createModelSelection("codex", "gpt-5.3-codex", [{ id: "fastMode", value: true }]), }, activeProvider: "codex", }); @@ -3830,14 +3821,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, + claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), }, stickyActiveProvider: "claudeAgent", }); @@ -3865,14 +3852,10 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, + claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), }, activeProvider: "claudeAgent", }); @@ -3912,14 +3895,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, + codex: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ]), }, stickyActiveProvider: "codex", }); @@ -3947,25 +3926,18 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + codex: createModelSelection("codex", "gpt-5.3-codex", [{ id: "fastMode", value: true }]), }, activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(draftId, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }); + useComposerDraftStore.getState().setModelSelection( + draftId, + createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), + ); await newThreadButton.click(); @@ -3976,14 +3948,10 @@ describe("ChatView timeline estimator parity (full app)", () => { ); expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }, + codex: createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), }, activeProvider: "codex", }); @@ -5670,37 +5638,31 @@ describe("ChatView timeline estimator parity (full app)", () => { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), }, ], }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 535c0d9fcae..354802b5940 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - type ClaudeAgentEffort, type EnvironmentId, type MessageId, type ModelSelection, @@ -26,7 +25,11 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + createModelSelection, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -306,8 +309,21 @@ function formatOutgoingPrompt(params: { text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); + const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || + descriptor.id === "effort" || + descriptor.id === "reasoning" || + descriptor.id === "variant") && + (descriptor.promptInjectedValues?.length ?? 0) > 0, + ); + if ( + params.effort && + promptInjectedDescriptor?.type === "select" && + promptInjectedDescriptor.promptInjectedValues?.includes(params.effort) + ) { + return applyClaudePromptEffortPrefix(params.text, params.effort); } return params.text; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3d3b081af99..3b47b2067cd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -71,11 +71,10 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { - getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, -} from "./composerProviderRegistry"; +} from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-icons"; @@ -96,7 +95,11 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; +import { + getProviderInteractionModeToggle, + getProviderModels, + resolveSelectableProvider, +} from "../../providerModels"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; @@ -595,7 +598,7 @@ export const ChatComposer = memo( model: selectedModel, models: selectedProviderModels, prompt, - modelOptions: composerModelOptions, + modelOptions: composerModelOptions?.[selectedProvider], }), [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], ); @@ -603,8 +606,13 @@ export const ChatComposer = memo( const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const composerProviderControls = useMemo( - () => getComposerProviderControls(selectedProvider), - [selectedProvider], + () => ({ + showInteractionModeToggle: getProviderInteractionModeToggle( + providerStatuses, + selectedProvider, + ), + }), + [providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 7619a635545..1808b94033c 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -10,6 +10,7 @@ import "../../index.css"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { TraitsMenuContent } from "./TraitsPicker"; @@ -17,6 +18,34 @@ import { useComposerDraftStore } from "../../composerDraftStore"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, + promptInjectedValues?: ReadonlyArray, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + ...(promptInjectedValues && promptInjectedValues.length > 0 + ? { promptInjectedValues: [...promptInjectedValues] } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { const threadId = ThreadId.make("thread-compact-menu"); const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); @@ -33,11 +62,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str persistedAttachments: [], terminalContexts: [], modelSelectionByProvider: { - [provider]: { - provider, - model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), - }, + [provider]: createModelSelection(provider, model, props?.modelSelection?.options), }, activeProvider: provider, runtimeMode: null, @@ -51,74 +76,58 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str document.body.append(host); const onPromptChange = vi.fn(); const providerOptions = props?.modelSelection?.options; - const models = - provider === "claudeAgent" - ? [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - ] - : [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]; + const models = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor( + "effort", + "Reasoning", + [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + ["ultrathink"], + ), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("thinking", "Thinking")], + }), + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor( + "effort", + "Reasoning", + [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + ["ultrathink"], + ), + ], + }), + }, + ]; const screen = await render( { it("shows fast mode controls for Opus", async () => { await using _ = await mountMenu({ - modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6"), }); await page.getByLabelText("More composer controls").click(); @@ -177,14 +186,14 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); + expect(text).toContain("On"); + expect(text).toContain("Off"); }); }); it("hides fast mode controls for non-Opus Claude models", async () => { await using _ = await mountMenu({ - modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), }); await page.getByLabelText("More composer controls").click(); @@ -196,7 +205,7 @@ describe("CompactComposerControlsMenu", () => { it("shows only the provided effort options", async () => { await using _ = await mountMenu({ - modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), }); await page.getByLabelText("More composer controls").click(); @@ -213,11 +222,9 @@ describe("CompactComposerControlsMenu", () => { it("shows a Claude thinking on/off section for Haiku", async () => { await using _ = await mountMenu({ - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { thinking: true }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "thinking", value: true }, + ]), }); await page.getByLabelText("More composer controls").click(); @@ -225,18 +232,16 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); + expect(text).toContain("On"); expect(text).toContain("Off"); }); }); it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { await using _ = await mountMenu({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { effort: "high" }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "high" }, + ]), prompt: "Ultrathink:\nInvestigate this", }); @@ -244,18 +249,16 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Effort"); + expect(text).toContain("Reasoning"); expect(text).not.toContain("ultrathink"); }); }); it("warns when ultrathink appears in prompt body text", async () => { await using _ = await mountMenu({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { effort: "high" }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "high" }, + ]), prompt: "Ultrathink:\nplease ultrathink about this problem", }); @@ -264,7 +267,7 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', + 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', ); }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index f4854dd9a62..a5f539ae5ac 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,5 +1,6 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { EnvironmentId } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { page, userEvent } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -66,17 +67,34 @@ vi.mock("../../environments/runtime", () => { }; }); -function effort(value: string, isDefault = false) { +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { return { - value, - label: value, - ...(isDefault ? { isDefault: true } : {}), + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, }; } const TEST_PROVIDERS: ReadonlyArray = [ { provider: "codex", + displayName: "Codex", enabled: true, installed: true, version: "0.116.0", @@ -90,30 +108,37 @@ const TEST_PROVIDERS: ReadonlyArray = [ slug: "gpt-5-codex", name: "GPT-5 Codex", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, ], }, { provider: "claudeAgent", + displayName: "Claude", enabled: true, installed: true, version: "1.0.0", @@ -127,47 +152,48 @@ const TEST_PROVIDERS: ReadonlyArray = [ slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + { id: "max", label: "max" }, + ]), + booleanDescriptor("thinking", "Thinking"), ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + }), }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + { id: "max", label: "max" }, + ]), + booleanDescriptor("thinking", "Thinking"), ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + }), }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], }, @@ -176,6 +202,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { return { provider: "codex", + displayName: "Codex", enabled: true, installed: true, version: "0.116.0", @@ -464,13 +491,15 @@ describe("ProviderModelPicker", () => { subProvider: "GitHub Copilot", shortName: "Opus 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), }, ]), ]; @@ -658,13 +687,16 @@ describe("ProviderModelPicker", () => { slug: "gpt-5-codex", name: "GPT-5 Codex", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, ]), buildOpenCodeProvider([ @@ -673,13 +705,15 @@ describe("ProviderModelPicker", () => { name: "Claude Opus 4.7", subProvider: "GitHub Copilot", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), }, ]), ]; @@ -712,13 +746,15 @@ describe("ProviderModelPicker", () => { name: "Claude Opus 4.7", subProvider: "GitHub Copilot", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), }, ]), { @@ -728,18 +764,17 @@ describe("ProviderModelPicker", () => { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + { id: "max", label: "max" }, + ]), + booleanDescriptor("thinking", "Thinking"), ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + }), }, ], }, @@ -909,13 +944,16 @@ describe("ProviderModelPicker", () => { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, ]), TEST_PROVIDERS[1]!, @@ -926,25 +964,31 @@ describe("ProviderModelPicker", () => { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark", isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], + }), }, ]), TEST_PROVIDERS[1]!, diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index e709e75da37..5eaacafe052 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,7 +1,8 @@ -import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; +import { type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; +import { formatProviderKindLabel } from "../../providerModels"; export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, @@ -12,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ return null; } - const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; + const providerLabel = status.displayName?.trim() || formatProviderKindLabel(status.provider); const defaultMessage = status.status === "error" ? `${providerLabel} provider is unavailable.` diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx deleted file mode 100644 index dbfbf092878..00000000000 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ /dev/null @@ -1,821 +0,0 @@ -import "../../index.css"; - -import { - type ModelSelection, - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_SERVER_SETTINGS, - OpenCodeModelOptions, - EnvironmentId, - type ServerProvider, - ThreadId, -} from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import { page } from "vitest/browser"; -import { useCallback } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { TraitsPicker } from "./TraitsPicker"; -import { - COMPOSER_DRAFT_STORAGE_KEY, - ComposerThreadDraftState, - useComposerDraftStore, - useComposerThreadDraft, - useEffectiveComposerModelState, -} from "../../composerDraftStore"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; - -// ── Claude TraitsPicker tests ───────────────────────────────────────── - -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const CLAUDE_THREAD_ID = ThreadId.make("thread-claude-traits"); -const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); -const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); -const CODEX_THREAD_ID = ThreadId.make("thread-codex-traits"); -const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); -const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); -const TEST_PROVIDERS: ReadonlyArray = [ - { - provider: "codex", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, - { - provider: "opencode", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "openai/gpt-5", - name: "GPT-5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - ], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, - ], - }, - { - provider: "claudeAgent", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, -]; -const findTestProvider = (provider: ServerProvider["provider"]) => { - const testProvider = TEST_PROVIDERS.find((candidate) => candidate.provider === provider); - if (!testProvider) { - throw new Error(`Missing test provider fixture: ${provider}`); - } - return testProvider; -}; - -function ClaudeTraitsPickerHarness(props: { - model: string; - fallbackModelSelection: ModelSelection | null; - triggerVariant?: "ghost" | "outline"; -}) { - const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; - const setPrompt = useComposerDraftStore((store) => store.setPrompt); - const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadRef: CLAUDE_THREAD_REF, - providers: TEST_PROVIDERS, - selectedProvider: "claudeAgent", - threadModelSelection: props.fallbackModelSelection, - projectModelSelection: null, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...DEFAULT_CLIENT_SETTINGS, - }, - }); - const handlePromptChange = useCallback( - (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_REF, nextPrompt); - }, - [setPrompt], - ); - - return ( - - ); -} - -async function mountClaudePicker(props?: { - model?: string; - prompt?: string; - options?: ClaudeModelOptions; - fallbackModelOptions?: { - effort?: "low" | "medium" | "high" | "max" | "ultrathink"; - thinking?: boolean; - fastMode?: boolean; - } | null; - skipDraftModelOptions?: boolean; - triggerVariant?: "ghost" | "outline"; -}) { - const model = props?.model ?? "claude-opus-4-6"; - const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; - const draftsByThreadKey: Record = { - [CLAUDE_THREAD_KEY]: { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: props?.skipDraftModelOptions - ? {} - : { - claudeAgent: { - provider: "claudeAgent", - model, - ...(claudeOptions && Object.keys(claudeOptions).length > 0 - ? { options: claudeOptions } - : {}), - }, - }, - activeProvider: "claudeAgent", - runtimeMode: null, - interactionMode: null, - }, - }; - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const fallbackModelSelection = - props?.fallbackModelOptions !== undefined - ? ({ - provider: "claudeAgent", - model, - ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}), - } satisfies ModelSelection) - : null; - const screen = await render( - , - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Claude)", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls for Opus", async () => { - await using _ = await mountClaudePicker(); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("hides fast mode controls for non-Opus models", async () => { - await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountClaudePicker({ - model: "claude-sonnet-4-6", - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - }); - - it("shows a th inking on/off dropdown for Haiku", async () => { - await using _ = await mountClaudePicker({ - model: "claude-haiku-4-5", - options: { thinking: true }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Thinking On"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); - expect(text).toContain("Off"); - }); - }); - - it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "high" }, - prompt: "Ultrathink:\nInvestigate this", - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Ultrathink"); - expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Effort"); - expect(text).not.toContain("ultrathink"); - }); - }); - - it("warns when ultrathink appears in prompt body text", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "high" }, - prompt: "Ultrathink:\nplease ultrathink about this problem", - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', - ); - }); - }); - - it("persists sticky claude model options when traits change", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "medium", fastMode: false }, - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Max" }).click(); - - expect( - useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, - ).toMatchObject({ - provider: "claudeAgent", - options: { - effort: "max", - }, - }); - }); - - it("accepts outline trigger styling", async () => { - await using _ = await mountClaudePicker({ - triggerVariant: "outline", - }); - - const button = document.querySelector("button"); - if (!(button instanceof HTMLButtonElement)) { - throw new Error("Expected traits trigger button to be rendered."); - } - expect(button.className).toContain("border-input"); - expect(button.className).toContain("bg-popover"); - }); -}); - -// ── Codex TraitsPicker tests ────────────────────────────────────────── - -async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; - const draftsByThreadKey: Record = { - [CODEX_THREAD_KEY]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - codex: { - provider: "codex", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "codex", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - "environment-local:project-codex-traits": CODEX_THREAD_KEY, - }, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) { - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor; - const cursorThreadId = ThreadId.make("thread-cursor-traits"); - const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId); - const cursorThreadKey = scopedThreadKey(cursorThreadRef); - const host = document.createElement("div"); - document.body.append(host); - - useComposerDraftStore.setState({ - draftsByThreadKey: { - [cursorThreadKey]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - cursor: { - provider: "cursor", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "cursor", - runtimeMode: null, - interactionMode: null, - }, - }, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Codex)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("shows Fast in the trigger label when fast mode is active", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: true }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("High · Fast"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Extra High"); - expect(text).toContain("High"); - expect(text).not.toContain("Low"); - expect(text).not.toContain("Medium"); - }); - }); - - it("persists sticky codex model options when traits change", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ - provider: "codex", - options: { fastMode: true }, - }); - }); -}); - -// ── OpenCode TraitsPicker tests ─────────────────────────────────────── - -async function mountOpenCodePicker(props: { - model?: string; - options?: OpenCodeModelOptions; - models?: ServerProvider["models"]; -}) { - const threadId = ThreadId.make("thread-opencode-traits"); - const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); - const threadKey = scopedThreadKey(threadRef); - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode; - const draftsByThreadKey: Record = { - [threadKey]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - opencode: { - provider: "opencode", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "opencode", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (OpenCode)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows the selected agent label with capitalization in the trigger", async () => { - await using _ = await mountOpenCodePicker({ - options: { - variant: "medium", - agent: "plan", - }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Medium · Plan"); - expect(text).not.toContain("Medium · plan"); - }); - }); - - it("does not show a leading separator when only agent options are available", async () => { - await using _ = await mountOpenCodePicker({ - model: "openai/gpt-5.4", - models: [ - { - slug: "openai/gpt-5.4", - name: "OpenAI · GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, - ], - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Agent"); - expect(text).toContain("Build (default)"); - expect(text).toContain("Plan"); - expect(document.querySelectorAll('[data-slot="menu-separator"]')).toHaveLength(0); - }); - }); -}); - -describe("TraitsPicker (Cursor)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("uses the selected fast mode menu label for the trigger in fast-only state", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - options: { fastMode: false }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => { - await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("Context Window"); - expect(text).toContain("200K (default)"); - expect(text).toContain("1M"); - }); - }); - - it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => { - await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false, contextWindow: "1m" }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Normal · 1M"); - }); - }); -}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index d30fba832e6..1b54da76da5 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,21 +1,17 @@ import { - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, - type OpenCodeModelOptions, type ProviderKind, - type ProviderModelOptions, + type ProviderOptionDescriptor, + type ProviderOptionSelection, type ScopedThreadRef, type ServerProviderModel, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, isClaudeUltrathinkPrompt, - trimOrNull, - getDefaultEffort, - getDefaultContextWindow, - hasContextWindowOption, - resolveEffort, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -34,12 +30,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; -type ProviderOptions = ProviderModelOptions[ProviderKind]; -type NamedOption = { - value: string; - label: string; - isDefault?: boolean | undefined; -}; +type ProviderOptions = ReadonlyArray; type TraitsPersistence = | { @@ -54,62 +45,34 @@ type TraitsPersistence = const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; -function getRawEffort( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, -): string | null { - if (provider === "codex") { - return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); - } - if (provider === "cursor") { - return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning); - } - if (provider === "opencode") { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); - } - return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); -} - -function getEffortKey(provider: ProviderKind): string { - if (provider === "codex") return "reasoningEffort"; - if (provider === "cursor") return "reasoning"; - if (provider === "opencode") return "variant"; - return "effort"; -} - -function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); -} - -function resolveNamedOption( - options: ReadonlyArray, - raw: string | null, -): NamedOption | null { - if (raw) { - const matchingOption = options.find((option) => option.value === raw); - if (matchingOption) { - return matchingOption; - } - } - return options.find((option) => option.isDefault) ?? options[0] ?? null; +function replaceDescriptorCurrentValue( + descriptors: ReadonlyArray, + descriptorId: string, + currentValue: string | boolean | undefined, +): ReadonlyArray { + return descriptors.map((descriptor) => + descriptor.id !== descriptorId + ? descriptor + : descriptor.type === "boolean" + ? { + ...descriptor, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + } + : { + ...descriptor, + ...(typeof currentValue === "string" ? { currentValue } : {}), + }, + ); } -function getRawContextWindow( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, +function getDescriptorStringValue( + descriptor: Extract | null, ): string | null { - if (modelOptions && "contextWindow" in modelOptions) { - return trimOrNull(modelOptions.contextWindow); + if (!descriptor) { + return null; } - return null; -} - -function buildNextOptions( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, - patch: Record, -): ProviderOptions { - return { ...(modelOptions as Record | undefined), ...patch } as ProviderOptions; + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : null; } function getSelectedTraits( @@ -121,71 +84,68 @@ function getSelectedTraits( allowPromptInjectedEffort: boolean, ) { const caps = getProviderModelCapabilities(models, model, provider); - const effortLevels = - provider === "opencode" - ? (caps.variantOptions ?? []) - : allowPromptInjectedEffort - ? caps.reasoningEffortLevels - : caps.reasoningEffortLevels.filter( - (option) => !caps.promptInjectedEffortLevels.includes(option.value), - ); - - // Resolve effort from options (provider-specific key) - const rawEffort = getRawEffort(provider, modelOptions); - const effort = - provider === "opencode" - ? (resolveNamedOption(effortLevels, rawEffort)?.value ?? null) - : (resolveEffort(caps, rawEffort) ?? null); - - // Thinking toggle (only for models that support it) - const thinkingEnabled = caps.supportsThinkingToggle - ? modelOptions && "thinking" in modelOptions - ? modelOptions.thinking === true - : null - : null; - - // Fast mode - const fastModeEnabled = - caps.supportsFastMode && - (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; - - // Context window - const contextWindowOptions = caps.contextWindowOptions; - const rawContextWindow = getRawContextWindow(provider, modelOptions); - const defaultContextWindow = getDefaultContextWindow(caps); - const contextWindow = - rawContextWindow && hasContextWindowOption(caps, rawContextWindow) - ? rawContextWindow - : defaultContextWindow; + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelOptions, + }); + const selectDescriptors = descriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "select", + ); + const booleanDescriptors = descriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "boolean", + ); + const primarySelectDescriptor = selectDescriptors[0] ?? null; + const contextWindowDescriptor = + selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null; + const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null; + const fastModeDescriptor = + booleanDescriptors.find((descriptor) => descriptor.id === "fastMode") ?? null; + const thinkingDescriptor = + booleanDescriptors.find((descriptor) => descriptor.id === "thinking") ?? null; // Prompt-controlled effort (e.g. ultrathink in prompt text) const ultrathinkPromptControlled = allowPromptInjectedEffort && - caps.promptInjectedEffortLevels.length > 0 && + (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && isClaudeUltrathinkPrompt(prompt); // Check if "ultrathink" appears in the body text (not just our prefix) const ultrathinkInBodyText = ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); - - const agentOptions = caps.agentOptions ?? []; - const selectedAgentOption = - provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null; + const effort = + (ultrathinkPromptControlled + ? "ultrathink" + : getDescriptorStringValue(primarySelectDescriptor)) ?? null; + const thinkingEnabled = + typeof thinkingDescriptor?.currentValue === "boolean" ? thinkingDescriptor.currentValue : null; + const fastModeEnabled = + typeof fastModeDescriptor?.currentValue === "boolean" ? fastModeDescriptor.currentValue : false; + const contextWindow = getDescriptorStringValue(contextWindowDescriptor); + const selectedAgent = getDescriptorStringValue(agentDescriptor); + const selectedAgentLabel = agentDescriptor + ? getProviderOptionCurrentLabel(agentDescriptor) + : null; return { caps, + descriptors, + selectDescriptors, + booleanDescriptors, + primarySelectDescriptor, + contextWindowDescriptor, + agentDescriptor, + fastModeDescriptor, + thinkingDescriptor, effort, - effortLevels, thinkingEnabled, fastModeEnabled, - contextWindowOptions, contextWindow, - defaultContextWindow, ultrathinkPromptControlled, ultrathinkInBodyText, - agentOptions, - selectedAgent: selectedAgentOption?.value ?? null, - selectedAgentLabel: selectedAgentOption?.label ?? null, + selectedAgent, + selectedAgentLabel, }; } @@ -206,11 +166,11 @@ function getTraitsSectionVisibility(input: { input.allowPromptInjectedEffort ?? true, ); - const showEffort = selected.effort !== null; - const showThinking = selected.thinkingEnabled !== null; - const showFastMode = selected.caps.supportsFastMode; - const showContextWindow = selected.contextWindowOptions.length > 1; - const showAgent = selected.agentOptions.length > 0; + const showEffort = selected.primarySelectDescriptor !== null; + const showThinking = selected.thinkingDescriptor !== null; + const showFastMode = selected.fastModeDescriptor !== null; + const showContextWindow = selected.contextWindowDescriptor !== null; + const showAgent = selected.agentDescriptor !== null; return { ...selected, @@ -275,22 +235,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ [model, persistence, provider, setProviderModelOptions], ); const { - caps, - effort, - effortLevels, - thinkingEnabled, - fastModeEnabled, - contextWindowOptions, - contextWindow, - defaultContextWindow, + descriptors, + selectDescriptors, + booleanDescriptors, + primarySelectDescriptor, ultrathinkPromptControlled, - showEffort, - showThinking, - showFastMode, - showContextWindow, ultrathinkInBodyText, - agentOptions, - selectedAgent, hasAnyControls, } = getTraitsSectionVisibility({ provider, @@ -300,53 +250,30 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelOptions, allowPromptInjectedEffort, }); - const defaultEffort = getDefaultEffort(caps); - const showsEffortSection = showEffort; - const showsThinkingSection = !showEffort && showThinking; - const showsFastModeSection = showFastMode; - const showsContextWindowSection = showContextWindow; - const hasSectionsBeforeAgent = - showsEffortSection || showsThinkingSection || showsFastModeSection || showsContextWindowSection; + const updateDescriptors = (nextDescriptors: ReadonlyArray) => { + updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); + }; - const handleEffortChange = useCallback( - (value: string) => { - if (!value) return; - const nextOption = effortLevels.find((option) => option.value === value); - if (!nextOption) return; - if (provider === "opencode") { - updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); - return; - } - if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - onPromptChange(nextPrompt); - return; - } - if (ultrathinkInBodyText) return; - if (ultrathinkPromptControlled) { - const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); - onPromptChange(stripped); - } - const effortKey = getEffortKey(provider); - updateModelOptions( - buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), - ); - }, - [ - ultrathinkPromptControlled, - ultrathinkInBodyText, - modelOptions, - onPromptChange, - updateModelOptions, - effortLevels, - prompt, - caps.promptInjectedEffortLevels, - provider, - ], - ); + const handleSelectChange = ( + descriptor: Extract, + value: string, + ) => { + if (!value) return; + if (descriptor.promptInjectedValues?.includes(value)) { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + onPromptChange(nextPrompt); + return; + } + if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return; + if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { + const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); + onPromptChange(stripped); + } + updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); + }; if (!hasAnyControls) { return null; @@ -354,121 +281,62 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> - {showsEffortSection ? ( - <> + {selectDescriptors.map((descriptor, index) => ( +
+ {index > 0 ? : null}
- {provider === "opencode" ? "Variant" : "Effort"} + {descriptor.label}
- {ultrathinkInBodyText ? ( + {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? (
- Your prompt contains "ultrathink" in the text. Remove it to change effort. + Your prompt contains "ultrathink" in the text. Remove it to change this + option.
) : null} handleSelectChange(descriptor, value)} > - {effortLevels.map((option) => ( + {descriptor.options.map((option) => ( {option.label} - {(provider === "opencode" ? option.isDefault : option.value === defaultEffort) - ? " (default)" - : ""} + {option.isDefault ? " (default)" : ""} ))}
- - ) : showsThinkingSection ? ( - -
Thinking
- { - updateModelOptions( - buildNextOptions(provider, modelOptions, { thinking: value === "on" }), - ); - }} - > - On (default) - Off - -
- ) : null} - {showsFastModeSection ? ( - <> - {showsEffortSection || showsThinkingSection ? : null} - -
Fast Mode
- { - updateModelOptions( - buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), - ); - }} - > - off - on - -
- - ) : null} - {showsContextWindowSection ? ( - <> - {showsEffortSection || showsThinkingSection || showsFastModeSection ? ( - - ) : null} +
+ ))} + {booleanDescriptors.map((descriptor, index) => ( +
+ {index > 0 || selectDescriptors.length > 0 ? : null}
- Context Window + {descriptor.label}
{ - updateModelOptions( - buildNextOptions(provider, modelOptions, { - contextWindow: value, - }), + updateDescriptors( + replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), ); }} > - {contextWindowOptions.map((option) => ( - - {option.label} - {option.value === defaultContextWindow ? " (default)" : ""} - - ))} - -
- - ) : null} - {agentOptions.length > 0 ? ( - <> - {hasSectionsBeforeAgent ? : null} - -
Agent
- { - updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value })); - }} - > - {agentOptions.map((option) => ( - - {option.label} - {option.isDefault ? " (default)" : ""} - - ))} + On + Off
- - ) : null} +
+ ))} ); }); @@ -486,52 +354,15 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const { - caps, - effort, - effortLevels, - thinkingEnabled, - fastModeEnabled, - contextWindowOptions, - contextWindow, - defaultContextWindow, - ultrathinkPromptControlled, - showEffort, - showThinking, - showContextWindow, - } = getTraitsSectionVisibility({ - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - }); - const { selectedAgentLabel } = getSelectedTraits( - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - ); - - const effortLabel = effort - ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) - : null; - const primaryTraitLabel = ultrathinkPromptControlled - ? "Ultrathink" - : effortLabel - ? effortLabel - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`; - const contextWindowLabel = - showContextWindow && contextWindow !== defaultContextWindow - ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) - : null; - const fastOnlyControl = - caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow; + const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + getTraitsSectionVisibility({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + }); if ( !shouldRenderTraitsControls({ provider, @@ -545,27 +376,22 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const selectedTriggerTraits = [ - primaryTraitLabel, - ...(caps.supportsFastMode && - (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null)) - ? [fastModeEnabled ? "Fast" : "Normal"] - : []), - ...(contextWindowLabel ? [contextWindowLabel] : []), - ...(selectedAgentLabel ? [selectedAgentLabel] : []), - ].filter(Boolean); - const triggerLabel = fastOnlyControl - ? fastModeEnabled - ? "Fast" - : "Normal" - : selectedTriggerTraits.length > 0 - ? selectedTriggerTraits.join(" · ") - : caps.supportsFastMode - ? "Normal" - : defaultContextWindow - ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ?? - defaultContextWindow) - : (selectedAgentLabel ?? ""); + const triggerLabel = + descriptors + .map((descriptor) => { + if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { + return "Ultrathink"; + } + if (descriptor.type === "boolean") { + if (descriptor.id === "fastMode") { + return descriptor.currentValue === true ? "Fast" : "Normal"; + } + return `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`; + } + return getProviderOptionCurrentLabel(descriptor); + }) + .filter((label): label is string => typeof label === "string" && label.length > 0) + .join(" · ") || ""; const isCodexStyle = provider === "codex"; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx deleted file mode 100644 index c4dd2cbb6ee..00000000000 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ /dev/null @@ -1,516 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ServerProviderModel } from "@t3tools/contracts"; -import { - getComposerProviderControls, - getComposerProviderState, - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./composerProviderRegistry"; - -const CODEX_MODELS: ReadonlyArray = [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const CLAUDE_MODELS: ReadonlyArray = [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray = [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const OPENCODE_MODELS: ReadonlyArray = [ - { - slug: "openai/gpt-5", - name: "GPT-5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - ], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, -]; - -describe("getComposerProviderState", () => { - it("returns codex defaults when no codex draft options exist", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: undefined, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - }, - }); - }); - - it("normalizes codex dispatch options while preserving the selected effort", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "low", - modelOptionsForDispatch: { - reasoningEffort: "low", - fastMode: true, - }, - }); - }); - - it("preserves codex fast mode when it is the only active option", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - fastMode: true, - }, - }); - }); - - it("preserves codex default effort explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - reasoningEffort: "high", - fastMode: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - fastMode: false, - }, - }); - }); - - it("returns Claude defaults for effort-capable models", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: undefined, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "high", - modelOptionsForDispatch: { - effort: "high", - }, - }); - }); - - it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "Ultrathink:\nInvestigate this failure", - modelOptions: { - claudeAgent: { - effort: "medium", - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "medium", - modelOptionsForDispatch: { - effort: "medium", - }, - composerFrameClassName: "ultrathink-frame", - composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", - modelPickerIconClassName: "ultrathink-chroma", - }); - }); - - it("drops unsupported Claude effort options for models without effort controls", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "max", - thinking: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: null, - modelOptionsForDispatch: { - thinking: false, - }, - }); - }); - - it("preserves Claude fast mode when it is the only active option", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "high", - modelOptionsForDispatch: { - effort: "high", - fastMode: true, - }, - }); - }); - - it("preserves Claude default effort explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "high", - modelOptionsForDispatch: { - effort: "high", - fastMode: false, - }, - }); - }); - - it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => { - // Regression: normalizeClaudeModelOptionsWithCapabilities used to strip - // fastMode: false, which meant deepMerge could never clear a previous true. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: false, - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); - }); - - it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => { - // Regression: thinking: true (the default) used to be stripped, which - // meant deepMerge could never clear a previous thinking: false. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - thinking: true, - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); - }); - - it("preserves Claude default context window explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - contextWindow: "200k", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toMatchObject({ - effort: "high", - contextWindow: "200k", - }); - }); - - it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => { - // Regression: the default contextWindow must survive normalization so - // deepMerge can clear an older non-default 1m selection. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - contextWindow: "200k", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k"); - }); - - it("omits contextWindow when the model does not support it", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - contextWindow: "1m", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toBeUndefined(); - }); - - it("omits fastMode when the model does not support it", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: true, - }, - }, - }); - - expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); - }); - - it("preserves OpenCode variant and agent options for dispatch", () => { - const state = getComposerProviderState({ - provider: "opencode", - model: "openai/gpt-5", - models: OPENCODE_MODELS, - prompt: "", - modelOptions: { - opencode: { - variant: "medium", - agent: "plan", - }, - }, - }); - - expect(state).toEqual({ - provider: "opencode", - promptEffort: "medium", - modelOptionsForDispatch: { - variant: "medium", - agent: "plan", - }, - }); - }); -}); - -describe("getComposerProviderControls", () => { - it("hides the interaction mode toggle for OpenCode", () => { - expect(getComposerProviderControls("opencode")).toEqual({ - showInteractionModeToggle: false, - }); - }); - - it("keeps the interaction mode toggle for Codex and Claude", () => { - expect(getComposerProviderControls("codex")).toEqual({ - showInteractionModeToggle: true, - }); - expect(getComposerProviderControls("claudeAgent")).toEqual({ - showInteractionModeToggle: true, - }); - }); -}); - -describe("provider traits render guards", () => { - it("returns null for codex traits picker when no thread target is provided", () => { - const content = renderProviderTraitsPicker({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - modelOptions: undefined, - prompt: "", - onPromptChange: () => {}, - }); - - expect(content).toBeNull(); - }); - - it("returns null for claude traits menu content when no thread target is provided", () => { - const content = renderProviderTraitsMenuContent({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - modelOptions: undefined, - prompt: "", - onPromptChange: () => {}, - }); - - expect(content).toBeNull(); - }); -}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx deleted file mode 100644 index af03c99a5e7..00000000000 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - type ProviderKind, - type ProviderModelOptions, - type ScopedThreadRef, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { - isClaudeUltrathinkPrompt, - normalizeProviderModelOptionsWithCapabilities, - resolveEffort, - trimOrNull, -} from "@t3tools/shared/model"; -import type { ReactNode } from "react"; - -import type { DraftId } from "../../composerDraftStore"; -import { getProviderModelCapabilities } from "../../providerModels"; -import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; - -export type ComposerProviderStateInput = { - provider: ProviderKind; - model: string; - models: ReadonlyArray; - prompt: string; - modelOptions: ProviderModelOptions | null | undefined; -}; - -export type ComposerProviderState = { - provider: ProviderKind; - promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; - composerFrameClassName?: string; - composerSurfaceClassName?: string; - modelPickerIconClassName?: string; -}; - -type TraitsRenderInput = { - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}; - -export type ComposerProviderControls = { - showInteractionModeToggle: boolean; -}; - -type ProviderRegistryEntry = { - controls: ComposerProviderControls; - getState: (input: ComposerProviderStateInput) => ComposerProviderState; - renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode; - renderTraitsPicker: (input: TraitsRenderInput) => ReactNode; -}; - -function hasComposerTraitsTarget(input: { - threadRef: ScopedThreadRef | undefined; - draftId: DraftId | undefined; -}): boolean { - return input.threadRef !== undefined || input.draftId !== undefined; -} - -function renderTraitsControl( - Component: typeof TraitsMenuContent | typeof TraitsPicker, - provider: ProviderKind, - input: TraitsRenderInput, -): ReactNode { - const { threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = input; - if ( - !hasComposerTraitsTarget({ threadRef, draftId }) || - !shouldRenderTraitsControls({ - provider, - models, - model, - modelOptions, - prompt, - }) - ) { - return null; - } - - return ( - - ); -} - -function getProviderStateFromCapabilities( - input: ComposerProviderStateInput, -): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; - const caps = getProviderModelCapabilities(models, model, provider); - const providerOptions = modelOptions?.[provider]; - const rawEffort = providerOptions - ? "effort" in providerOptions - ? providerOptions.effort - : "reasoningEffort" in providerOptions - ? providerOptions.reasoningEffort - : "reasoning" in providerOptions - ? providerOptions.reasoning - : "variant" in providerOptions - ? providerOptions.variant - : null - : null; - const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( - provider, - caps, - providerOptions, - ); - const promptEffort = - provider === "opencode" - ? (trimOrNull( - normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, - ) ?? null) - : (resolveEffort(caps, rawEffort) ?? null); - const ultrathinkActive = - caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); - - return { - provider, - promptEffort, - modelOptionsForDispatch: normalizedOptions, - ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), - ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } - : {}), - ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), - }; -} - -const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = { - showInteractionModeToggle: true, -}; - -function createProviderRegistryEntry( - provider: ProviderKind, - controls?: Partial, -): ProviderRegistryEntry { - return { - controls: { - ...DEFAULT_PROVIDER_CONTROLS, - ...controls, - }, - getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: (input) => renderTraitsControl(TraitsMenuContent, provider, input), - renderTraitsPicker: (input) => renderTraitsControl(TraitsPicker, provider, input), - }; -} - -const composerProviderRegistry: Record = { - codex: createProviderRegistryEntry("codex"), - claudeAgent: createProviderRegistryEntry("claudeAgent"), - cursor: createProviderRegistryEntry("cursor"), - opencode: createProviderRegistryEntry("opencode", { - showInteractionModeToggle: false, - }), -}; - -export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - return composerProviderRegistry[input.provider].getState(input); -} - -export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls { - return composerProviderRegistry[provider].controls; -} - -export function renderProviderTraitsMenuContent(input: { - provider: ProviderKind; - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}): ReactNode { - return composerProviderRegistry[input.provider].renderTraitsMenuContent({ - ...(input.threadRef ? { threadRef: input.threadRef } : {}), - ...(input.draftId ? { draftId: input.draftId } : {}), - model: input.model, - models: input.models, - modelOptions: input.modelOptions, - prompt: input.prompt, - onPromptChange: input.onPromptChange, - }); -} - -export function renderProviderTraitsPicker(input: { - provider: ProviderKind; - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}): ReactNode { - return composerProviderRegistry[input.provider].renderTraitsPicker({ - ...(input.threadRef ? { threadRef: input.threadRef } : {}), - ...(input.draftId ? { draftId: input.draftId } : {}), - model: input.model, - models: input.models, - modelOptions: input.modelOptions, - prompt: input.prompt, - onPromptChange: input.onPromptChange, - }); -} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx new file mode 100644 index 00000000000..cc49cbf5dfc --- /dev/null +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import type { + ProviderKind, + ProviderOptionDescriptor, + ProviderOptionSelection, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderState"; + +// Everything in composerProviderState is now data-driven by the model's +// optionDescriptors, so these tests use a single synthetic provider/model and +// vary only the descriptor shape per scenario. + +const PROVIDER: ProviderKind = "codex"; +const MODEL = "test-model"; + +function selectDescriptor( + id: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, + promptInjectedValues?: ReadonlyArray, +): Extract { + const defaultId = options.find((option) => option.isDefault)?.id; + return { + id, + label: id, + type: "select", + options: [...options], + ...(defaultId ? { currentValue: defaultId } : {}), + ...(promptInjectedValues && promptInjectedValues.length > 0 + ? { promptInjectedValues: [...promptInjectedValues] } + : {}), + }; +} + +function booleanDescriptor(id: string): Extract { + return { id, label: id, type: "boolean" }; +} + +function modelWith( + descriptors: ReadonlyArray, +): ReadonlyArray { + return [ + { slug: MODEL, name: MODEL, isCustom: false, capabilities: { optionDescriptors: descriptors } }, + ]; +} + +function selections( + ...entries: Array<[string, string | boolean]> +): ReadonlyArray { + return entries.map(([id, value]) => ({ id, value })); +} + +const ULTRATHINK_FRAME_CLASSES = { + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", +} as const; + +describe("getComposerProviderState", () => { + it("returns descriptor defaults when no selections are provided", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + ]), + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "high", + modelOptionsForDispatch: selections(["effort", "high"]), + }); + }); + + it("lets selections override defaults and propagates them through dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode"), + ]), + prompt: "", + modelOptions: selections(["effort", "low"], ["fastMode", true]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "low", + modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), + }); + }); + + it("preserves selections that match defaults so deepMerge can overwrite prior state", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + booleanDescriptor("fastMode"), + ]), + prompt: "", + modelOptions: selections(["effort", "high"], ["fastMode", false]), + }); + + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["fastMode", false]), + ); + }); + + it("drops selections for descriptors the model does not declare", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([booleanDescriptor("thinking")]), + prompt: "", + modelOptions: selections(["effort", "max"], ["thinking", false]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["thinking", false]), + }); + }); + + it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + selectDescriptor("contextWindow", [ + { id: "200k", label: "200k", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ]), + prompt: "", + modelOptions: selections(["agent", "plan"]), + }); + + expect(state.promptEffort).toBe("high"); + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]), + ); + }); + + it("returns undefined dispatch options when the model declares no descriptors", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([]), + prompt: "", + modelOptions: selections(["anything", "value"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor( + "effort", + [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + ["ultrathink"], + ), + ]), + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: selections(["effort", "medium"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "medium", + modelOptionsForDispatch: selections(["effort", "medium"]), + ...ULTRATHINK_FRAME_CLASSES, + }); + }); + + it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]), + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: undefined, + }); + + expect(state).not.toHaveProperty("composerFrameClassName"); + expect(state).not.toHaveProperty("composerSurfaceClassName"); + expect(state).not.toHaveProperty("modelPickerIconClassName"); + }); +}); + +describe("provider traits render guards", () => { + it("returns null when no thread target is provided", () => { + const models = modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const args = { + provider: PROVIDER, + model: MODEL, + models, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }; + + expect(renderProviderTraitsPicker(args)).toBeNull(); + expect(renderProviderTraitsMenuContent(args)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx new file mode 100644 index 00000000000..b817786563a --- /dev/null +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -0,0 +1,108 @@ +import { + type ProviderKind, + type ProviderOptionSelection, + type ScopedThreadRef, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + isClaudeUltrathinkPrompt, +} from "@t3tools/shared/model"; +import type { ReactNode } from "react"; + +import type { DraftId } from "../../composerDraftStore"; +import { getProviderModelCapabilities } from "../../providerModels"; +import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; + +export type ComposerProviderStateInput = { + provider: ProviderKind; + model: string; + models: ReadonlyArray; + prompt: string; + modelOptions: ReadonlyArray | null | undefined; +}; + +export type ComposerProviderState = { + provider: ProviderKind; + promptEffort: string | null; + modelOptionsForDispatch: ReadonlyArray | undefined; + composerFrameClassName?: string; + composerSurfaceClassName?: string; + modelPickerIconClassName?: string; +}; + +type TraitsRenderInput = { + provider: ProviderKind; + threadRef?: ScopedThreadRef; + draftId?: DraftId; + model: string; + models: ReadonlyArray; + modelOptions: ReadonlyArray | undefined; + prompt: string; + onPromptChange: (prompt: string) => void; +}; + +export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { + const { provider, model, models, prompt, modelOptions } = input; + const caps = getProviderModelCapabilities(models, model, provider); + const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); + const primarySelectDescriptor = descriptors.find( + (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => + descriptor.type === "select", + ); + const primaryValue = getProviderOptionCurrentValue(primarySelectDescriptor ?? null); + const promptEffort = typeof primaryValue === "string" ? primaryValue : null; + const ultrathinkActive = + (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && + isClaudeUltrathinkPrompt(prompt); + + return { + provider, + promptEffort, + modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), + ...(ultrathinkActive + ? { + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", + } + : {}), + }; +} + +function renderTraitsControl( + Component: typeof TraitsMenuContent | typeof TraitsPicker, + input: TraitsRenderInput, +): ReactNode { + const { provider, threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = + input; + const hasTarget = threadRef !== undefined || draftId !== undefined; + if ( + !hasTarget || + !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + ) { + return null; + } + return ( + + ); +} + +export function renderProviderTraitsMenuContent(input: TraitsRenderInput): ReactNode { + return renderTraitsControl(TraitsMenuContent, input); +} + +export function renderProviderTraitsPicker(input: TraitsRenderInput): ReactNode { + return renderTraitsControl(TraitsPicker, input); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 230b0a9965d..574a055e460 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -11,7 +11,6 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { - PROVIDER_DISPLAY_NAMES, type DesktopUpdateChannel, type ScopedThreadRef, type ProviderKind, @@ -20,8 +19,7 @@ import { } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { normalizeModelSlug } from "@t3tools/shared/model"; -import { createModelSelection } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; import { @@ -79,6 +77,7 @@ import { useServerObservability, useServerProviders, } from "../../rpc/serverState"; +import { formatProviderKindLabel } from "../../providerModels"; const THEME_OPTIONS = [ { @@ -1162,7 +1161,9 @@ export function GeneralSettingsPanel() { const customModelInput = customModelInputByProvider[providerCard.provider]; const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; const providerDisplayName = - PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; + providerCard.liveProvider?.displayName?.trim() || + providerCard.title || + formatProviderKindLabel(providerCard.provider); return (
@@ -1461,11 +1462,22 @@ export function GeneralSettingsPanel() { {providerCard.models.map((model) => { const caps = model.capabilities; const capLabels: string[] = []; - if (caps?.supportsFastMode) capLabels.push("Fast mode"); - if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + const descriptors = caps?.optionDescriptors ?? []; + if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { + capLabels.push("Fast mode"); + } + if (descriptors.some((descriptor) => descriptor.id === "thinking")) { + capLabels.push("Thinking"); + } if ( - caps?.reasoningEffortLevels && - caps.reasoningEffortLevels.length > 0 + descriptors.some( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || + descriptor.id === "effort" || + descriptor.id === "reasoning" || + descriptor.id === "variant"), + ) ) { capLabels.push("Reasoning"); } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d789d7d510a..9416b42ff93 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -10,8 +10,38 @@ import { ProjectId, ThreadId, type ModelSelection, - type ProviderModelOptions, + type ProviderKind, + type ProviderOptionSelection, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +type ProviderOptionSelectionBag = ReadonlyArray; +type ProviderOptionSelectionsByProvider = Partial>; + +function toSelections( + options: Record | undefined, +): ReadonlyArray { + const result: Array = []; + if (!options) return result; + for (const [id, value] of Object.entries(options)) { + if (typeof value === "string" || typeof value === "boolean") { + result.push({ id, value }); + } + } + return result; +} + +function selectionsByProvider( + options: Partial>>, +): ProviderOptionSelectionsByProvider { + const result: ProviderOptionSelectionsByProvider = {}; + for (const [provider, bag] of Object.entries(options) as Array< + [ProviderKind, Record] + >) { + result[provider] = toSelections(bag); + } + return result; +} import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -93,17 +123,15 @@ function resetComposerDraftStore() { function modelSelection( provider: "codex" | "claudeAgent" | "cursor", model: string, - options?: ModelSelection["options"], + options?: Record, ): ModelSelection { - return { - provider, - model, - ...(options ? { options } : {}), - } as ModelSelection; + return createModelSelection(provider, model, toSelections(options)); } -function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { - return options; +function providerModelOptions( + options: Partial>>, +): ProviderOptionSelectionsByProvider { + return selectionsByProvider(options); } const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -941,14 +969,9 @@ describe("composerDraftStore modelSelection", () => { }), ); - store.setProviderModelOptions( - threadRef, - "claudeAgent", - { - thinking: false, - }, - { persistSticky: true }, - ); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { + persistSticky: true, + }); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -972,9 +995,7 @@ describe("composerDraftStore modelSelection", () => { }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { - thinking: true, - }); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: true })); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -989,10 +1010,11 @@ describe("composerDraftStore modelSelection", () => { store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); - store.setProviderModelOptions(threadRef, "codex", { - reasoningEffort: "high", - fastMode: false, - }); + store.setProviderModelOptions( + threadRef, + "codex", + toSelections({ reasoningEffort: "high", fastMode: false }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { @@ -1014,11 +1036,11 @@ describe("composerDraftStore modelSelection", () => { }), ); - store.setProviderModelOptions(threadRef, "cursor", { - reasoning: "medium", - fastMode: false, - thinking: true, - }); + store.setProviderModelOptions( + threadRef, + "cursor", + toSelections({ reasoning: "medium", fastMode: false, thinking: true }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( modelSelection("cursor", "claude-opus-4-6", { @@ -1032,17 +1054,10 @@ describe("composerDraftStore modelSelection", () => { it("preserves the selected Cursor model when only traits change", () => { const store = useComposerDraftStore.getState(); - store.setProviderModelOptions( - threadRef, - "cursor", - { - reasoning: "high", - }, - { - model: "gpt-5.4", - persistSticky: true, - }, - ); + store.setProviderModelOptions(threadRef, "cursor", toSelections({ reasoning: "high" }), { + model: "gpt-5.4", + persistSticky: true, + }); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( modelSelection("cursor", "gpt-5.4", { @@ -1067,9 +1082,7 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { - thinking: false, - }); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false })); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -1097,8 +1110,13 @@ describe("composerDraftStore modelSelection", () => { store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual( + createModelSelection("codex", "gpt-5.4", toSelections({ reasoningEffort: "xhigh" })).options, + ); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( + createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) + .options, + ); }); it("preserves other provider options when switching the active model selection", () => { @@ -1118,7 +1136,9 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual( + createModelSelection("codex", "gpt-5.4", toSelections({ fastMode: true })).options, + ); expect(draft?.activeProvider).toBe("claudeAgent"); }); @@ -1127,14 +1147,9 @@ describe("composerDraftStore modelSelection", () => { store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); - store.setProviderModelOptions( - threadRef, - "codex", - { - fastMode: true, - }, - { persistSticky: true }, - ); + store.setProviderModelOptions(threadRef, "codex", toSelections({ fastMode: true }), { + persistSticky: true, + }); expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { @@ -1154,14 +1169,9 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - store.setProviderModelOptions( - threadRef, - "claudeAgent", - { - thinking: false, - }, - { persistSticky: false }, - ); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { + persistSticky: false, + }); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -1279,12 +1289,15 @@ describe("composerDraftStore provider-scoped option updates", () => { reasoningEffort: "medium", }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" })); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( + createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) + .options, + ); expect(draft?.activeProvider).toBe("codex"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 36304fc0236..cdd11a77b19 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,16 +1,11 @@ import { - CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - type CursorModelOptions, - type CursorReasoningOption, - ClaudeAgentEffort, - CodexReasoningEffort, type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, - ProviderModelOptions, + ProviderOptionSelection, RuntimeMode, type ServerProvider, type ScopedProjectRef, @@ -46,7 +41,7 @@ import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 5; +const COMPOSER_DRAFT_STORAGE_VERSION = 6; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); const isRuntimeMode = Schema.is(RuntimeMode); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -106,23 +101,30 @@ const PersistedComposerThreadDraftState = Schema.Struct({ }); type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; -const LegacyCodexFields = Schema.Struct({ - effort: Schema.optionalKey(CodexReasoningEffort), - codexFastMode: Schema.optionalKey(Schema.Boolean), - serviceTier: Schema.optionalKey(Schema.String), -}); -type LegacyCodexFields = typeof LegacyCodexFields.Type; +/** + * Per-provider record of generic option selections. Used as a transient + * representation when migrating legacy v2 storage payloads and when + * deriving per-provider option bundles for downstream consumers. + */ +type ProviderOptionSelectionsByProvider = Partial< + Record> +>; -const LegacyThreadModelFields = Schema.Struct({ - provider: Schema.optionalKey(ProviderKind), - model: Schema.optionalKey(Schema.String), - modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), -}); -type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; +type LegacyCodexFields = { + effort?: unknown; + codexFastMode?: unknown; + serviceTier?: unknown; +}; + +type LegacyThreadModelFields = { + provider?: unknown; + model?: unknown; + modelOptions?: unknown; +}; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; - modelOptions?: ProviderModelOptions | null; + modelOptions?: unknown; }; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & @@ -130,16 +132,15 @@ type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState LegacyThreadModelFields & LegacyV2ThreadDraftFields; -const LegacyStickyModelFields = Schema.Struct({ - stickyProvider: Schema.optionalKey(ProviderKind), - stickyModel: Schema.optionalKey(Schema.String), - stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), -}); -type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; +type LegacyStickyModelFields = { + stickyProvider?: unknown; + stickyModel?: unknown; + stickyModelOptions?: unknown; +}; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; - stickyModelOptions?: ProviderModelOptions | null; + stickyModelOptions?: unknown; projectDraftThreadIdByProjectId?: Record | null; draftsByThreadId?: Record | null; draftThreadsByThreadId?: Record | null; @@ -334,15 +335,19 @@ interface ComposerDraftStoreState { threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, ) => void; + /** Replace the model options for one or more providers in the draft. */ setModelOptions: ( threadRef: ComposerThreadTarget, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: + | Partial>> + | null + | undefined, ) => void; applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( threadRef: ComposerThreadTarget, provider: ProviderKind, - nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, + nextProviderOptions: ReadonlyArray | null | undefined, options?: { model?: string | null | undefined; persistSticky?: boolean; @@ -379,7 +384,7 @@ interface ComposerDraftStoreState { export interface EffectiveComposerModelState { selectedModel: string; - modelOptions: ProviderModelOptions | null; + modelOptions: ProviderOptionSelectionsByProvider | null; } interface ComposerDraftModelState { @@ -387,29 +392,48 @@ interface ComposerDraftModelState { modelSelectionByProvider: Partial>; } -function providerModelOptionsFromSelection( +function providerSelectionsFromModelSelection( modelSelection: ModelSelection | null | undefined, -): ProviderModelOptions | null { - if (!modelSelection?.options) { +): ProviderOptionSelectionsByProvider | null { + if (!modelSelection) { return null; } - - return { - [modelSelection.provider]: modelSelection.options, - }; + const options = modelSelection.options; + if (!options || options.length === 0) { + return null; + } + return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider; } function modelSelectionByProviderToOptions( map: Partial> | null | undefined, -): ProviderModelOptions | null { +): ProviderOptionSelectionsByProvider | null { if (!map) return null; - const result: Record = {}; + const result: Partial>> = {}; for (const [provider, selection] of Object.entries(map)) { - if (selection?.options) { - result[provider] = selection.options; + if (selection?.options && selection.options.length > 0) { + result[provider as ProviderKind] = selection.options; } } - return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null; + return Object.keys(result).length > 0 ? result : null; +} + +function cloneModelSelection(selection: ModelSelection): DeepMutable { + return { + ...selection, + ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}), + } as DeepMutable; +} + +function cloneModelSelectionByProvider( + selections: Partial>, +): DeepMutable>> { + return Object.fromEntries( + Object.entries(selections).map(([provider, selection]) => [ + provider, + selection ? cloneModelSelection(selection) : selection, + ]), + ) as DeepMutable>>; } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ @@ -536,149 +560,92 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { : null; } +/** + * Coerce an unknown value into a `ReadonlyArray`. + * Accepts either: + * - the v3 representation: an array of `{ id, value }` entries + * - the legacy v2 representation: a record of `{ id: string | boolean }` + * + * Validation is intentionally permissive: descriptors are the source of truth + * for which option ids are meaningful for a given provider/model. Anything + * outside the descriptor list is harmless trailing data and will simply be + * ignored downstream. + */ +function coerceProviderOptionSelections( + value: unknown, +): ReadonlyArray | undefined { + if (Array.isArray(value)) { + const out: ProviderOptionSelection[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const id = record.id; + const optionValue = record.value; + if (typeof id !== "string" || id.length === 0) continue; + if (typeof optionValue === "string" || typeof optionValue === "boolean") { + out.push({ id, value: optionValue }); + } + } + return out.length > 0 ? out : undefined; + } + if (value && typeof value === "object") { + const record = value as Record; + const out: ProviderOptionSelection[] = []; + for (const [id, raw] of Object.entries(record)) { + if (typeof raw === "string" || typeof raw === "boolean") { + out.push({ id, value: raw }); + } + } + return out.length > 0 ? out : undefined; + } + return undefined; +} + +/** + * Normalize a per-provider options bag from either the v3 or legacy v2 shape. + * + * `provider` and `legacy` parameters are migration-only inputs used to + * recover legacy codex fields (effort/codexFastMode/serviceTier) that lived + * directly on the draft instead of inside `modelOptions.codex`. + */ function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, legacy?: LegacyCodexFields, -): ProviderModelOptions | null { +): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; - const codexCandidate = - candidate?.codex && typeof candidate.codex === "object" - ? (candidate.codex as Record) - : null; - const claudeCandidate = - candidate?.claudeAgent && typeof candidate.claudeAgent === "object" - ? (candidate.claudeAgent as Record) - : null; - const cursorCandidate = - candidate?.cursor && typeof candidate.cursor === "object" - ? (candidate.cursor as Record) - : null; - const openCodeCandidate = - candidate?.opencode && typeof candidate.opencode === "object" - ? (candidate.opencode as Record) - : null; - - const isCodexReasoningEffort = Schema.is(CodexReasoningEffort); - const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort); - - const codexReasoningEffort = isCodexReasoningEffort(codexCandidate?.reasoningEffort) - ? codexCandidate.reasoningEffort - : provider === "codex" - ? isCodexReasoningEffort(legacy?.effort) - ? legacy.effort - : undefined - : undefined; - const codexFastMode = - codexCandidate?.fastMode === true - ? true - : codexCandidate?.fastMode === false - ? false - : (provider === "codex" && legacy?.codexFastMode === true) || - (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") - ? true - : undefined; - const codex = - codexReasoningEffort !== undefined || codexFastMode !== undefined - ? { - ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), - ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), - } - : undefined; - - const claudeThinking = - claudeCandidate?.thinking === true - ? true - : claudeCandidate?.thinking === false - ? false - : undefined; - const claudeEffort = isClaudeAgentEffort(claudeCandidate?.effort) - ? claudeCandidate.effort - : undefined; - const claudeFastMode = - claudeCandidate?.fastMode === true - ? true - : claudeCandidate?.fastMode === false - ? false - : undefined; - const claudeContextWindow = - typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0 - ? claudeCandidate.contextWindow - : undefined; - const claude = - claudeThinking !== undefined || - claudeEffort !== undefined || - claudeFastMode !== undefined || - claudeContextWindow !== undefined - ? { - ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), - ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), - ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), - ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), - } - : undefined; - - const cursorReasoningRaw = cursorCandidate?.reasoning; - const cursorReasoning: CursorReasoningOption | undefined = - typeof cursorReasoningRaw === "string" && - (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) - ? (cursorReasoningRaw as CursorReasoningOption) - : undefined; - const cursorFastMode = - cursorCandidate?.fastMode === true - ? true - : cursorCandidate?.fastMode === false - ? false - : undefined; - const cursorThinking = - cursorCandidate?.thinking === true - ? true - : cursorCandidate?.thinking === false - ? false - : undefined; - const cursorContextWindow = - typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 - ? cursorCandidate.contextWindow - : undefined; - - const cursor: CursorModelOptions | undefined = - cursorCandidate !== null - ? (() => { - const nextCursor = { - ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), - ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), - ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), - ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), - } satisfies CursorModelOptions; - return Object.keys(nextCursor).length > 0 ? nextCursor : undefined; - })() - : undefined; - - const openCodeVariant = - typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 - ? openCodeCandidate.variant - : undefined; - const openCodeAgent = - typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 - ? openCodeCandidate.agent - : undefined; - const opencode = - openCodeVariant !== undefined || openCodeAgent !== undefined - ? { - ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), - ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), - } - : undefined; + const result: Partial>> = {}; + for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + const selections = coerceProviderOptionSelections(candidate?.[providerKey]); + if (selections) { + result[providerKey] = selections; + } + } - if (!codex && !claude && cursor === undefined && !opencode) { - return null; + // Recover legacy codex fields that lived outside modelOptions. + if (provider === "codex" && legacy) { + const codexExtras: ProviderOptionSelection[] = []; + if (typeof legacy.effort === "string" && legacy.effort.length > 0) { + codexExtras.push({ id: "reasoningEffort", value: legacy.effort }); + } + const fastMode = + legacy.codexFastMode === true || + (typeof legacy.serviceTier === "string" && legacy.serviceTier === "fast"); + if (fastMode) { + codexExtras.push({ id: "fastMode", value: true }); + } + if (codexExtras.length > 0) { + const existing = result.codex ?? []; + const existingIds = new Set(existing.map((entry) => entry.id)); + const merged = [...existing]; + for (const extra of codexExtras) { + if (!existingIds.has(extra.id)) merged.push(extra); + } + result.codex = merged; + } } - return { - ...(codex ? { codex } : {}), - ...(claude ? { claudeAgent: claude } : {}), - ...(cursor !== undefined ? { cursor } : {}), - ...(opencode ? { opencode } : {}), - }; + + return Object.keys(result).length > 0 ? result : null; } function normalizeModelSelection( @@ -703,6 +670,10 @@ function normalizeModelSelection( if (!model) { return null; } + if (Array.isArray(candidate?.options)) { + const selections = coerceProviderOptionSelections(candidate.options); + return createModelSelection(provider, model, selections); + } const modelOptions = normalizeProviderModelOptions( candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, provider, @@ -716,7 +687,7 @@ function normalizeModelSelection( function legacySyncModelSelectionOptions( modelSelection: ModelSelection | null, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: ProviderOptionSelectionsByProvider | null | undefined, ): ModelSelection | null { if (modelSelection === null) { return null; @@ -727,9 +698,9 @@ function legacySyncModelSelectionOptions( function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, - currentModelOptions: ProviderModelOptions | null | undefined, -): ProviderModelOptions | null { - if (modelSelection?.options === undefined) { + currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, +): ProviderOptionSelectionsByProvider | null { + if (!modelSelection?.options || modelSelection.options.length === 0) { return normalizeProviderModelOptions(currentModelOptions); } return legacyReplaceProviderModelOptions( @@ -740,35 +711,30 @@ function legacyMergeModelSelectionIntoProviderModelOptions( } function legacyReplaceProviderModelOptions( - currentModelOptions: ProviderModelOptions | null | undefined, + currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, provider: ProviderKind, - nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, -): ProviderModelOptions | null { + nextProviderOptions: ReadonlyArray | null | undefined, +): ProviderOptionSelectionsByProvider | null { const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = currentModelOptions ?? {}; - const normalizedNextProviderOptions = normalizeProviderModelOptions( - { [provider]: nextProviderOptions }, - provider, - ); - - return normalizeProviderModelOptions({ - ...otherProviderModelOptions, - ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}), - }); + const merged: ProviderOptionSelectionsByProvider = { ...otherProviderModelOptions }; + if (nextProviderOptions && nextProviderOptions.length > 0) { + merged[provider] = nextProviderOptions; + } + return Object.keys(merged).length > 0 ? merged : null; } // ── New helpers for the consolidated representation ──────────────────── function legacyToModelSelectionByProvider( modelSelection: ModelSelection | null, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: ProviderOptionSelectionsByProvider | null | undefined, ): Partial> { const result: Partial> = {}; - // Add entries from the options bag (for non-active providers) if (modelOptions) { for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { const options = modelOptions[provider]; - if (options && Object.keys(options).length > 0) { + if (options && options.length > 0) { result[provider] = createModelSelection( provider, modelSelection?.provider === provider @@ -779,7 +745,6 @@ function legacyToModelSelectionByProvider( } } } - // Add/overwrite the active selection (it's authoritative for its provider) if (modelSelection) { result[modelSelection.provider] = modelSelection; } @@ -813,8 +778,8 @@ export function deriveEffectiveComposerModelState(input: { : baseModel; const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? - providerModelOptionsFromSelection(input.threadModelSelection) ?? - providerModelOptionsFromSelection(input.projectModelSelection) ?? + providerSelectionsFromModelSelection(input.threadModelSelection) ?? + providerSelectionsFromModelSelection(input.projectModelSelection) ?? null; return { @@ -1425,7 +1390,7 @@ function normalizePersistedDraftsByThreadId( { provider: legacyDraftCandidate.provider, model: legacyDraftCandidate.model, - modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, + modelOptions: normalizedModelOptions ?? (legacyDraftCandidate.modelOptions as unknown), legacyCodex: legacyDraftCandidate, }, ); @@ -1472,7 +1437,12 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), - ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), + ...(hasModelData + ? { + modelSelectionByProvider: cloneModelSelectionByProvider(modelSelectionByProvider), + activeProvider, + } + : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -1573,7 +1543,7 @@ function partializeComposerDraftStoreState( : {}), ...(hasModelData ? { - modelSelectionByProvider: draft.modelSelectionByProvider, + modelSelectionByProvider: cloneModelSelectionByProvider(draft.modelSelectionByProvider), activeProvider: draft.activeProvider, } : {}), @@ -2304,27 +2274,24 @@ const composerDraftStore = create()( if (threadKey.length === 0) { return; } - const normalizedOpts = normalizeProviderModelOptions(modelOptions); set((state) => { const existing = state.draftsByThreadKey[threadKey]; - if (!existing && normalizedOpts === null) { + if (!existing && (!modelOptions || Object.keys(modelOptions).length === 0)) { return state; } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { - // Only touch providers explicitly present in the input - if (!normalizedOpts || !(provider in normalizedOpts)) continue; - const opts = normalizedOpts[provider]; + if (!modelOptions || !(provider in modelOptions)) continue; + const opts = modelOptions[provider]; const current = nextMap[provider]; - if (opts) { + if (opts && opts.length > 0) { nextMap[provider] = createModelSelection( provider, current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], opts, ); } else if (current?.options) { - // Remove options but keep the selection const { options: _, ...rest } = current; nextMap[provider] = rest as ModelSelection; } @@ -2357,12 +2324,8 @@ const composerDraftStore = create()( const fallbackModel = normalizeModelSlug(options?.model, normalizedProvider) ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider]; - // Normalize just this provider's options - const normalizedOpts = normalizeProviderModelOptions( - { [normalizedProvider]: nextProviderOptions }, - normalizedProvider, - ); - const providerOpts = normalizedOpts?.[normalizedProvider]; + const providerOpts = + nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined; set((state) => { const existing = state.draftsByThreadKey[threadKey]; @@ -2377,7 +2340,7 @@ const composerDraftStore = create()( currentForProvider?.model ?? fallbackModel, providerOpts, ); - } else if (currentForProvider?.options) { + } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; } @@ -2397,7 +2360,7 @@ const composerDraftStore = create()( stickyBase.model, providerOpts, ); - } else if (stickyBase.options) { + } else if ((stickyBase.options?.length ?? 0) > 0) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2ffa8e20140..80e9e241e78 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -9,7 +9,7 @@ import { normalizeModelSlug, resolveSelectableModel, } from "@t3tools/shared/model"; -import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { getComposerProviderState } from "./components/chat/composerProviderState"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { getDefaultServerModel, @@ -21,14 +21,6 @@ import { ModelEsque } from "./components/chat/providerIconUtils"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export type ProviderCustomModelConfig = { - provider: ProviderKind; - title: string; - description: string; - placeholder: string; - example: string; -}; - export interface AppModelOption { slug: string; name: string; @@ -37,39 +29,6 @@ export interface AppModelOption { isCustom: boolean; } -const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { - codex: { - provider: "codex", - title: "Codex", - description: "Save additional Codex model slugs for the picker and `/model` command.", - placeholder: "your-codex-model-slug", - example: "gpt-6.7-codex-ultra-preview", - }, - claudeAgent: { - provider: "claudeAgent", - title: "Claude", - description: "Save additional Claude model slugs for the picker and `/model` command.", - placeholder: "your-claude-model-slug", - example: "claude-sonnet-5-0", - }, - cursor: { - provider: "cursor", - title: "Cursor", - description: "Save additional Cursor model slugs for the picker and `/model` command.", - placeholder: "your-cursor-model-slug", - example: "claude-sonnet-4-6", - }, - opencode: { - provider: "opencode", - title: "OpenCode", - description: "Save additional OpenCode model slugs in `provider/model` format.", - placeholder: "openai/gpt-5", - example: "anthropic/claude-sonnet-4-5-20250929", - }, -}; - -export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); - export function normalizeCustomModelSlugs( models: Iterable, builtInModelSlugs: ReadonlySet, @@ -222,9 +181,7 @@ export function resolveAppModelSelectionState( model, models: getProviderModels(providers, provider), prompt: "", - modelOptions: { - [provider]: provider === selection.provider ? selection.options : undefined, - }, + modelOptions: provider === selection.provider ? selection.options : undefined, }); return createModelSelection(provider, model, modelOptionsForDispatch); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index e901a895f49..229d61ca568 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,25 +1,23 @@ import { DEFAULT_MODEL_BY_PROVIDER, - type CursorModelOptions, type ModelCapabilities, type ProviderKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { - hasEffortLevel, - normalizeModelSlug, - resolveContextWindow, - trimOrNull, -} from "@t3tools/shared/model"; +import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); -const EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +export function formatProviderKindLabel(provider: ProviderKind): string { + return provider + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} export function getProviderModels( providers: ReadonlyArray, @@ -35,6 +33,21 @@ export function getProviderSnapshot( return providers.find((candidate) => candidate.provider === provider); } +export function getProviderDisplayName( + providers: ReadonlyArray, + provider: ProviderKind, +): string { + const snapshot = getProviderSnapshot(providers, provider); + return snapshot?.displayName?.trim() || formatProviderKindLabel(provider); +} + +export function getProviderInteractionModeToggle( + providers: ReadonlyArray, + provider: ProviderKind, +): boolean { + return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; +} + export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderKind, @@ -76,30 +89,3 @@ export function getDefaultServerModel( DEFAULT_MODEL_BY_PROVIDER[provider] ); } - -export function normalizeCursorModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CursorModelOptions | null | undefined, -): CursorModelOptions | undefined { - const reasoning = trimOrNull(modelOptions?.reasoning); - const reasoningValue = - reasoning && hasEffortLevel(caps, reasoning) - ? (reasoning as CursorModelOptions["reasoning"]) - : undefined; - const fastMode = - caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" - ? modelOptions.fastMode - : undefined; - const thinking = - caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" - ? modelOptions.thinking - : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: CursorModelOptions = { - ...(reasoningValue ? { reasoning: reasoningValue } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(thinking !== undefined ? { thinking } : {}), - ...(contextWindow ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5bb82caf421..88ebfb71230 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -2,86 +2,56 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import type { ProviderKind } from "./orchestration.ts"; -export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; -export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); -export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; -export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); -export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; -export type ClaudeCodeEffort = ClaudeAgentEffort; -export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; -export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS); -export type CursorReasoningOption = typeof CursorReasoningOption.Type; +export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); +export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type; -export type ProviderReasoningEffort = - | CodexReasoningEffort - | ClaudeAgentEffort - | CursorReasoningOption; - -export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(CodexReasoningEffort), - fastMode: Schema.optional(Schema.Boolean), +export const ProviderOptionChoice = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + isDefault: Schema.optional(Schema.Boolean), }); -export type CodexModelOptions = typeof CodexModelOptions.Type; +export type ProviderOptionChoice = typeof ProviderOptionChoice.Type; -export const ClaudeModelOptions = Schema.Struct({ - thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(ClaudeAgentEffort), - fastMode: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), -}); -export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +const ProviderOptionDescriptorBase = { + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), +} as const; -export const CursorModelOptions = Schema.Struct({ - reasoning: Schema.optional(CursorReasoningOption), - fastMode: Schema.optional(Schema.Boolean), - thinking: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), -}); -export type CursorModelOptions = typeof CursorModelOptions.Type; -export const OpenCodeModelOptions = Schema.Struct({ - variant: Schema.optional(TrimmedNonEmptyString), - agent: Schema.optional(TrimmedNonEmptyString), +export const SelectProviderOptionDescriptor = Schema.Struct({ + ...ProviderOptionDescriptorBase, + type: Schema.Literal("select"), + options: Schema.Array(ProviderOptionChoice), + currentValue: Schema.optional(TrimmedNonEmptyString), + promptInjectedValues: Schema.optional(Schema.Array(TrimmedNonEmptyString)), }); -export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; +export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type; -export const ProviderModelOptions = Schema.Struct({ - codex: Schema.optional(CodexModelOptions), - claudeAgent: Schema.optional(ClaudeModelOptions), - cursor: Schema.optional(CursorModelOptions), - opencode: Schema.optional(OpenCodeModelOptions), +export const BooleanProviderOptionDescriptor = Schema.Struct({ + ...ProviderOptionDescriptorBase, + type: Schema.Literal("boolean"), + currentValue: Schema.optional(Schema.Boolean), }); -export type ProviderModelOptions = typeof ProviderModelOptions.Type; +export type BooleanProviderOptionDescriptor = typeof BooleanProviderOptionDescriptor.Type; -export const EffortOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), -}); -export type EffortOption = typeof EffortOption.Type; +export const ProviderOptionDescriptor = Schema.Union([ + SelectProviderOptionDescriptor, + BooleanProviderOptionDescriptor, +]); +export type ProviderOptionDescriptor = typeof ProviderOptionDescriptor.Type; -export const ContextWindowOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), +export const ProviderOptionSelectionValue = Schema.Union([TrimmedNonEmptyString, Schema.Boolean]); +export type ProviderOptionSelectionValue = typeof ProviderOptionSelectionValue.Type; + +export const ProviderOptionSelection = Schema.Struct({ + id: TrimmedNonEmptyString, + value: ProviderOptionSelectionValue, }); -export type ContextWindowOption = typeof ContextWindowOption.Type; +export type ProviderOptionSelection = typeof ProviderOptionSelection.Type; export const ModelCapabilities = Schema.Struct({ - reasoningEffortLevels: Schema.Array(EffortOption), - supportsFastMode: Schema.Boolean, - supportsThinkingToggle: Schema.Boolean, - contextWindowOptions: Schema.Array(ContextWindowOption), - promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), - variantOptions: Schema.optional(Schema.Array(EffortOption)), - agentOptions: Schema.optional(Schema.Array(EffortOption)), + optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }); export type ModelCapabilities = typeof ModelCapabilities.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 223efd6d270..18ba2d165d2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -33,6 +33,13 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); + +function getOptionValue( + options: ReadonlyArray<{ id: string; value: unknown }> | undefined, + id: string, +): unknown { + return options?.find((option) => option.id === id)?.value; +} const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); @@ -364,16 +371,16 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.modelSelection?.provider, "codex"); - assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); - assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 087a6670901..30bed60a479 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,10 +1,5 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - OpenCodeModelOptions, -} from "./model.ts"; +import { ProviderOptionSelection } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, @@ -51,27 +46,27 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ provider: Schema.Literal("codex"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(CodexModelOptions), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(ClaudeModelOptions), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const CursorModelSelection = Schema.Struct({ provider: Schema.Literal("cursor"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(CursorModelOptions), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }); export type CursorModelSelection = typeof CursorModelSelection.Type; export const OpenCodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(OpenCodeModelOptions), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }); export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index f91d0c89e0a..9bbc3ff10b6 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -6,6 +6,13 @@ import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts" const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); +function getOptionValue( + options: ReadonlyArray<{ id: string; value: unknown }> | undefined, + id: string, +): unknown { + return options?.find((option) => option.id === id)?.value; +} + describe("ProviderSessionStartInput", () => { it("accepts codex-compatible payloads", () => { const parsed = decodeProviderSessionStartInput({ @@ -15,10 +22,10 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, runtimeMode: "full-access", }); @@ -28,8 +35,8 @@ describe("ProviderSessionStartInput", () => { if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } - expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("high"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); it("rejects payloads without runtime mode", () => { @@ -49,11 +56,11 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - thinking: true, - effort: "max", - fastMode: true, - }, + options: [ + { id: "thinking", value: true }, + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], }, runtimeMode: "full-access", }); @@ -63,9 +70,9 @@ describe("ProviderSessionStartInput", () => { if (parsed.modelSelection?.provider !== "claudeAgent") { throw new Error("Expected claude modelSelection"); } - expect(parsed.modelSelection.options?.thinking).toBe(true); - expect(parsed.modelSelection.options?.effort).toBe("max"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "thinking")).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("max"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); expect(parsed.runtimeMode).toBe("full-access"); }); @@ -78,14 +85,14 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "cursor", model: "composer-2", - options: { fastMode: true }, + options: [{ id: "fastMode", value: true }], }, }); expect(parsed.provider).toBe("cursor"); expect(parsed.modelSelection?.provider).toBe("cursor"); expect(parsed.modelSelection?.model).toBe("composer-2"); if (parsed.modelSelection?.provider === "cursor") { - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); } }); }); @@ -97,10 +104,10 @@ describe("ProviderSendTurnInput", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ], }, }); @@ -109,8 +116,8 @@ describe("ProviderSendTurnInput", () => { if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } - expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("xhigh"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); it("accepts claude modelSelection including ultrathink", () => { @@ -119,10 +126,10 @@ describe("ProviderSendTurnInput", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - fastMode: true, - }, + options: [ + { id: "effort", value: "ultrathink" }, + { id: "fastMode", value: true }, + ], }, }); @@ -130,7 +137,7 @@ describe("ProviderSendTurnInput", () => { if (parsed.modelSelection?.provider !== "claudeAgent") { throw new Error("Expected claude modelSelection"); } - expect(parsed.modelSelection.options?.effort).toBe("ultrathink"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("ultrathink"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3cd25f2e8e9..1f048c13407 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -85,6 +85,9 @@ export type ServerProviderSkill = typeof ServerProviderSkill.Type; export const ServerProvider = Schema.Struct({ provider: ProviderKind, + displayName: Schema.optional(TrimmedNonEmptyString), + badgeLabel: Schema.optional(TrimmedNonEmptyString), + showInteractionModeToggle: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cad1d197c12..0e93de82642 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,13 +2,7 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; -import { - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - OpenCodeModelOptions, -} from "./model.ts"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProviderOptionSelection } from "./model.ts"; import { ModelSelection, ProviderKind } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -170,50 +164,26 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── -const CodexModelOptionsPatch = Schema.Struct({ - reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), - fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), -}); - -const ClaudeModelOptionsPatch = Schema.Struct({ - thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), - effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), - fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), - contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), -}); - -const CursorModelOptionsPatch = Schema.Struct({ - reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), - fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), - thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), - contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), -}); - -const OpenCodeModelOptionsPatch = Schema.Struct({ - variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), - agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), -}); - const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(CodexModelOptionsPatch), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("claudeAgent")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(ClaudeModelOptionsPatch), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("cursor")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(CursorModelOptionsPatch), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("opencode")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(OpenCodeModelOptionsPatch), + options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), }), ]); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 426ceca865e..8c8ea3a30d3 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,46 +3,63 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/cont import { applyClaudePromptEffortPrefix, - getDefaultContextWindow, - getDefaultEffort, - hasContextWindowOption, - hasEffortLevel, + buildProviderOptionSelectionsFromDescriptors, + createModelCapabilities, + createModelSelection, + getProviderOptionDescriptors, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, normalizeModelSlug, - resolveContextWindow, - resolveEffort, resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, } from "./model.ts"; -const codexCaps: ModelCapabilities = { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, +const codexCaps: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "xhigh", label: "Extra High" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +}); -const claudeCaps: ModelCapabilities = { - reasoningEffortLevels: [ - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, +const claudeCaps: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + currentValue: "1m", + }, ], - promptInjectedEffortLevels: ["ultrathink"], -}; +}); describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { @@ -86,54 +103,6 @@ describe("resolveSelectableModel", () => { }); }); -describe("capability helpers", () => { - it("reads default efforts", () => { - expect(getDefaultEffort(codexCaps)).toBe("high"); - expect(getDefaultEffort(claudeCaps)).toBe("high"); - }); - - it("checks effort support", () => { - expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); - expect(hasEffortLevel(codexCaps, "max")).toBe(false); - }); -}); - -describe("resolveEffort", () => { - it("returns the explicit value when supported and not prompt-injected", () => { - expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); - expect(resolveEffort(codexCaps, "high")).toBe("high"); - expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); - }); - - it("falls back to default when value is unsupported", () => { - expect(resolveEffort(codexCaps, "bogus")).toBe("high"); - expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveEffort(codexCaps, undefined)).toBe("high"); - expect(resolveEffort(codexCaps, null)).toBe("high"); - expect(resolveEffort(codexCaps, "")).toBe("high"); - expect(resolveEffort(codexCaps, " ")).toBe("high"); - }); - - it("excludes prompt-injected efforts and falls back to default", () => { - expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); - }); - - it("returns undefined for models with no effort levels", () => { - const noCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; - expect(resolveEffort(noCaps, undefined)).toBeUndefined(); - expect(resolveEffort(noCaps, "high")).toBeUndefined(); - }); -}); - describe("misc helpers", () => { it("detects ultrathink prompts", () => { expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); @@ -156,95 +125,70 @@ describe("misc helpers", () => { }); }); -describe("context window helpers", () => { - it("reads default context window", () => { - expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); - }); - - it("returns null for models without context window options", () => { - expect(getDefaultContextWindow(codexCaps)).toBeNull(); - }); - - it("checks context window support", () => { - expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); - expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); - }); -}); - -describe("resolveContextWindow", () => { - it("returns the explicit value when supported", () => { - expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); - expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); - }); - - it("falls back to default when value is unsupported", () => { - expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); - }); - - it("returns undefined for models with no context window options", () => { - expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); - expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); - }); -}); - -describe("normalize*ModelOptionsWithCapabilities", () => { - it("preserves explicit false codex fast mode", () => { +describe("descriptor helpers", () => { + it("applies selection values to capability descriptors", () => { expect( - normalizeCodexModelOptionsWithCapabilities(codexCaps, { - reasoningEffort: "high", - fastMode: false, + getProviderOptionDescriptors({ + caps: claudeCaps, + selections: [ + { id: "effort", value: "medium" }, + { id: "contextWindow", value: "200k" }, + ], }), - ).toEqual({ - reasoningEffort: "high", - fastMode: false, + ).toEqual([ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "medium", + promptInjectedValues: ["ultrathink"], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + currentValue: "200k", + }, + ]); + }); + + it("builds wire-format option selections from descriptors", () => { + const descriptors = getProviderOptionDescriptors({ + caps: codexCaps, + selections: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }); - }); - it("preserves the default Claude context window explicitly", () => { - expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), - ).toEqual({ - effort: "high", - contextWindow: "200k", - }); + expect(buildProviderOptionSelectionsFromDescriptors(descriptors)).toEqual([ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); }); - it("omits unsupported Claude context window options", () => { + it("stores option selection arrays in model selections", () => { expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), + createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), ).toEqual({ - thinking: true, + provider: "codex", + model: "gpt-5.4", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ad62debf68c..03169fdcc1d 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,15 +1,11 @@ import { DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeAgentEffort, - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, type ModelCapabilities, type ModelSelection, - type OpenCodeModelOptions, + type ProviderOptionDescriptor, + type ProviderOptionSelection, type ProviderKind, - type ProviderModelOptions, } from "@t3tools/contracts"; export interface SelectableModelOption { @@ -17,157 +13,185 @@ export interface SelectableModelOption { name: string; } -/** Check whether a capabilities object includes a given effort value. */ -export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { - return caps.reasoningEffortLevels.some((l) => l.value === value); +export function createModelCapabilities(input: { + optionDescriptors: ReadonlyArray; +}): ModelCapabilities { + return { + optionDescriptors: input.optionDescriptors.map(cloneDescriptor), + }; +} + +function getRawSelectionValueById( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + const selection = selections?.find((candidate) => candidate.id === id); + return selection?.value; } -/** Return the default effort value for a capabilities object, or null if none. */ -export function getDefaultEffort(caps: ModelCapabilities): string | null { - return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; +export function getProviderOptionSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + return getRawSelectionValueById(selections, id); } -/** - * Resolve a raw effort option against capabilities. - * - * Returns the explicit supported value when present and not prompt-injected, - * otherwise the model default. Returns `undefined` when the model exposes no - * effort levels. - */ -export function resolveEffort( - caps: ModelCapabilities, +export function getModelSelectionOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): string | boolean | undefined { + return getProviderOptionSelectionValue(modelSelection?.options, id); +} + +function resolveDescriptorChoiceValue( + descriptor: Extract, raw: string | null | undefined, ): string | undefined { - const defaultValue = getDefaultEffort(caps); - const trimmed = typeof raw === "string" ? raw.trim() : null; + const trimmed = trimOrNull(raw); + if (!trimmed) { + return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; + } + if (descriptor.options.length === 0) { + return trimmed; + } if ( - trimmed && - !caps.promptInjectedEffortLevels.includes(trimmed) && - hasEffortLevel(caps, trimmed) + descriptor.promptInjectedValues?.includes(trimmed) && + descriptor.options.some((option) => option.id === trimmed) ) { + return descriptor.options.find((option) => option.isDefault)?.id; + } + if (descriptor.options.some((option) => option.id === trimmed)) { return trimmed; } - return defaultValue ?? undefined; + return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; } -/** Check whether a capabilities object includes a given context window value. */ -export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { - return caps.contextWindowOptions.some((o) => o.value === value); +function cloneDescriptor(descriptor: ProviderOptionDescriptor): ProviderOptionDescriptor { + return descriptor.type === "select" + ? { + ...descriptor, + options: [...descriptor.options], + ...(descriptor.promptInjectedValues + ? { promptInjectedValues: [...descriptor.promptInjectedValues] } + : {}), + } + : { ...descriptor }; } -/** Return the default context window value, or `null` if none is defined. */ -export function getDefaultContextWindow(caps: ModelCapabilities): string | null { - return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; +function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection { + return { ...selection }; } -/** - * Resolve a raw `contextWindow` option against capabilities. - * - * Returns the explicit supported value when present, otherwise the model - * default. Returns `undefined` when the model exposes no context window options. - */ -export function resolveContextWindow( - caps: ModelCapabilities, - raw: string | null | undefined, -): string | undefined { - const defaultValue = getDefaultContextWindow(caps); - if (!raw) return defaultValue ?? undefined; - return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); -} - -export function normalizeCodexModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastMode !== undefined ? { fastMode } : {}), +function withDescriptorCurrentValue( + descriptor: ProviderOptionDescriptor, + rawCurrentValue: string | boolean | undefined, +): ProviderOptionDescriptor { + if (descriptor.type === "boolean") { + if (typeof rawCurrentValue === "boolean") { + return { + ...descriptor, + currentValue: rawCurrentValue, + }; + } + return descriptor; + } + const currentValue = + typeof rawCurrentValue === "string" + ? resolveDescriptorChoiceValue(descriptor, rawCurrentValue) + : resolveDescriptorChoiceValue(descriptor, descriptor.currentValue); + if (!currentValue) { + const { currentValue: _unusedCurrentValue, ...rest } = descriptor; + return rest; + } + return { + ...descriptor, + currentValue, }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } -export function normalizeClaudeModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const effort = resolveEffort(caps, modelOptions?.effort); - const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: ClaudeModelOptions = { - ...(thinking !== undefined ? { thinking } : {}), - ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function getProviderOptionDescriptors(input: { + caps: ModelCapabilities; + selections?: ReadonlyArray | null | undefined; +}): ReadonlyArray { + const { caps, selections } = input; + const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); + + return baseDescriptors.map((descriptor) => + withDescriptorCurrentValue( + descriptor, + getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue, + ), + ); } -export function normalizeCursorModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CursorModelOptions | null | undefined, -): CursorModelOptions | undefined { - const reasoning = resolveEffort(caps, modelOptions?.reasoning); - const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: CursorModelOptions = { - ...(reasoning ? { reasoning: reasoning as CursorModelOptions["reasoning"] } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(thinking !== undefined ? { thinking } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function getProviderOptionCurrentValue( + descriptor: ProviderOptionDescriptor | null | undefined, +): string | boolean | undefined { + if (!descriptor) { + return undefined; + } + if (descriptor.type === "boolean") { + return descriptor.currentValue; + } + if (descriptor.currentValue) { + return descriptor.currentValue; + } + return descriptor.options.find((option) => option.isDefault)?.id; } -function resolveLabeledOption( - options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, - raw: string | null | undefined, +export function getProviderOptionCurrentLabel( + descriptor: ProviderOptionDescriptor | null | undefined, ): string | undefined { - if (!options || options.length === 0) { - return raw ?? undefined; + if (!descriptor) { + return undefined; + } + if (descriptor.type === "boolean") { + return typeof descriptor.currentValue === "boolean" + ? descriptor.currentValue + ? "On" + : "Off" + : undefined; } - if (raw && options.some((option) => option.value === raw)) { - return raw; + const currentValue = getProviderOptionCurrentValue(descriptor); + if (typeof currentValue !== "string") { + return undefined; } - return options.find((option) => option.isDefault)?.value ?? options[0]?.value; + return descriptor.options.find((option) => option.id === currentValue)?.label; } -export function normalizeOpenCodeModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: OpenCodeModelOptions | null | undefined, -): OpenCodeModelOptions | undefined { - const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); - const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); - const nextOptions: OpenCodeModelOptions = { - ...(variant ? { variant } : {}), - ...(agent ? { agent } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function buildProviderOptionSelectionsFromDescriptors( + descriptors: ReadonlyArray | null | undefined, +): Array | undefined { + if (!descriptors || descriptors.length === 0) { + return undefined; + } + + const nextSelections: Array = []; + + for (const descriptor of descriptors) { + const value = getProviderOptionCurrentValue(descriptor); + if (typeof value === "string" || typeof value === "boolean") { + nextSelections.push({ id: descriptor.id, value }); + } + } + + return nextSelections.length > 0 ? nextSelections : undefined; } -export function normalizeProviderModelOptionsWithCapabilities( - provider: ProviderKind, - caps: ModelCapabilities, - modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, -): ProviderModelOptions[ProviderKind] | undefined { - switch (provider) { - case "codex": - return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); - case "claudeAgent": - return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); - case "cursor": - return normalizeCursorModelOptionsWithCapabilities(caps, modelOptions as CursorModelOptions); - case "opencode": - return normalizeOpenCodeModelOptionsWithCapabilities( - caps, - modelOptions as OpenCodeModelOptions, - ); +export function getModelSelectionOptionDescriptors( + modelSelection: ModelSelection | null | undefined, + caps?: ModelCapabilities | null | undefined, +): ReadonlyArray { + if (!modelSelection) { + return []; + } + if (!caps) { + return []; } + return getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -249,42 +273,28 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +function cloneSelections( + selections: ReadonlyArray, +): Array { + return selections.map(cloneSelection); +} + export function createModelSelection( provider: ProviderKind, model: string, - options?: ProviderModelOptions[ProviderKind] | undefined, + options?: ReadonlyArray | null, ): ModelSelection { - switch (provider) { - case "codex": - return { - provider, - model, - ...(options ? { options: options as CodexModelOptions } : {}), - }; - case "claudeAgent": - return { - provider, - model, - ...(options ? { options: options as ClaudeModelOptions } : {}), - }; - case "cursor": - return { - provider, - model, - ...(options ? { options: options as CursorModelOptions } : {}), - }; - case "opencode": - return { - provider, - model, - ...(options ? { options: options as OpenCodeModelOptions } : {}), - }; - } + const selections = options ? cloneSelections(options) : []; + return { + provider, + model, + ...(selections.length > 0 ? { options: selections } : {}), + } as ModelSelection; } export function applyClaudePromptEffortPrefix( text: string, - effort: ClaudeAgentEffort | null | undefined, + effort: string | null | undefined, ): string { const trimmed = text.trim(); if (!trimmed) { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index bbe5d8dc2af..ccae816cf5c 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; +import { createModelSelection } from "./model.ts"; import { applyServerSettingsPatch, extractPersistedServerObservabilitySettings, @@ -56,14 +57,10 @@ describe("serverSettings helpers", () => { it("replaces text generation selection when provider/model are provided", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( @@ -82,45 +79,35 @@ describe("serverSettings helpers", () => { it("still deep merges text generation selection when only options are provided", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( applyServerSettingsPatch(current, { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }).textGenerationModelSelection, ).toEqual({ provider: "codex", model: "gpt-5.4-mini", - options: { - reasoningEffort: "high", - fastMode: false, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ], }); }); it("replaces text generation selection across providers without leaking stale options", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( @@ -135,4 +122,26 @@ describe("serverSettings helpers", () => { model: "openai/gpt-5", }); }); + + it("accepts array-based text generation selection patches", () => { + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + textGenerationModelSelection: { + provider: "opencode", + model: "openai/gpt-5", + options: [ + { id: "variant", value: "prod" }, + { id: "agent", value: "build" }, + ], + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "opencode", + model: "openai/gpt-5", + options: [ + { id: "variant", value: "prod" }, + { id: "agent", value: "build" }, + ], + }); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index cb9d9373573..2488f602934 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,14 +1,8 @@ -import { - ServerSettings, - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, - type OpenCodeModelOptions, - type ServerSettingsPatch, -} from "@t3tools/contracts"; +import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { Schema } from "effect"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; +import { createModelSelection } from "./model.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -53,8 +47,23 @@ function shouldReplaceTextGenerationModelSelection( return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); } -const withModelSelectionOptions = (options: Options | undefined) => - options ? { options } : {}; +function mergeModelSelectionOptionsById(input: { + current: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; + patch: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; +}): Array<{ id: string; value: string | boolean }> | undefined { + if (input.patch === undefined) { + return input.current ? [...input.current] : undefined; + } + if (input.patch.length === 0) { + return undefined; + } + + const merged = new Map((input.current ?? []).map((selection) => [selection.id, selection.value])); + for (const selection of input.patch) { + merged.set(selection.id, selection.value); + } + return [...merged.entries()].map(([id, value]) => ({ id, value })); +} /** * Applies a server settings patch while treating textGenerationModelSelection as @@ -67,44 +76,21 @@ export function applyServerSettingsPatch( ): ServerSettings { const selectionPatch = patch.textGenerationModelSelection; const next = deepMerge(current, patch); - if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { + if (!selectionPatch) { return next; } const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; const model = selectionPatch.model ?? current.textGenerationModelSelection.model; + const options = shouldReplaceTextGenerationModelSelection(selectionPatch) + ? selectionPatch.options + : mergeModelSelectionOptionsById({ + current: current.textGenerationModelSelection.options, + patch: selectionPatch.options, + }); return { ...next, - textGenerationModelSelection: - provider === "codex" - ? { - provider, - model, - ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined), - } - : provider === "claudeAgent" - ? { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as ClaudeModelOptions | undefined, - ), - } - : provider === "cursor" - ? { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as CursorModelOptions | undefined, - ), - } - : { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as OpenCodeModelOptions | undefined, - ), - }, + textGenerationModelSelection: createModelSelection(provider, model, options), }; } From 2ddec37d1e2e822e4f236c2cb7181b81af0c21df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 21 Apr 2026 17:12:58 -0700 Subject: [PATCH 2/7] Add migration 026 to canonicalize modelSelection options Migration 016 introduced modelSelection with `options` stored as a per-provider object (e.g. `{ effort: "max", fastMode: true }`). The recent contracts refactor reshaped `options` to an array of generic `{ id, value }` selections. Stored rows from before the reshape still carry the object shape and fail to decode with `Schema.fromJsonString( ModelSelection)`. This migration rewrites the legacy object shape into the canonical array shape in three places: - projection_threads.model_selection_json.options - projection_projects.default_model_selection_json.options - orchestration_events.payload_json modelSelection / defaultModelSelection (thread.created, thread.meta-updated, thread.turn-start-requested, project.created, project.meta-updated) String values are kept if non-empty after trim; boolean values are always kept; any other value type (nested objects, numbers, null) is dropped. This matches the permissive client-side normalizer in composerDraftStore. Records whose `options` is already an array are left untouched. --- apps/server/src/persistence/Migrations.ts | 2 + ..._CanonicalizeModelSelectionOptions.test.ts | 452 ++++++++++++++++++ .../026_CanonicalizeModelSelectionOptions.ts | 138 ++++++ 3 files changed, 592 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts create mode 100644 apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 023e3bca051..c8d4647fb72 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -38,6 +38,7 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; /** * Migration loader with all migrations defined inline. @@ -75,6 +76,7 @@ export const migrationEntries = [ [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], + [26, "CanonicalizeModelSelectionOptions", Migration0026], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts new file mode 100644 index 00000000000..527df8fdfe0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -0,0 +1,452 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("026_CanonicalizeModelSelectionOptions", (it) => { + it.effect( + "converts legacy object-shape options into array-shape on projections and events", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + 'project-legacy', + 'Legacy options project', + '/tmp/legacy', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-no-options', + 'No options project', + '/tmp/no-options', + '{"provider":"codex","model":"gpt-5.4"}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-null-selection', + 'Null model selection project', + '/tmp/null-selection', + NULL, + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-already-array', + 'Already-canonical options project', + '/tmp/already-array', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at, + runtime_mode, + interaction_mode + ) + VALUES + ( + 'thread-legacy', + 'project-legacy', + 'Legacy thread', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-empty-options', + 'project-legacy', + 'Empty options thread', + '{"provider":"codex","model":"gpt-5.4","options":{}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-drop-garbage', + 'project-legacy', + 'Thread with non-scalar entries', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-no-options', + 'project-legacy', + 'No options thread', + '{"provider":"codex","model":"gpt-5.4"}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-already-array', + 'project-legacy', + 'Already array thread', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-project-created', + 'project', + 'project-legacy', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'cmd-pc', + NULL, + 'corr-pc', + 'user', + '{"projectId":"project-legacy","title":"Project","workspaceRoot":"/tmp/legacy","defaultModelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-meta-updated', + 'project', + 'project-legacy', + 2, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-pmu', + NULL, + 'corr-pmu', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-null-selection', + 'project', + 'project-legacy', + 3, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-null', + NULL, + 'corr-null', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-created', + 'thread', + 'thread-legacy', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-tc', + NULL, + 'corr-tc', + 'user', + '{"threadId":"thread-legacy","projectId":"project-legacy","title":"Thread","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-meta-updated', + 'thread', + 'thread-legacy', + 2, + 'thread.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-tmu', + NULL, + 'corr-tmu', + 'user', + '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-turn-start', + 'thread', + 'thread-legacy', + 3, + 'thread.turn-start-requested', + '2026-01-01T00:00:00.000Z', + 'cmd-tts', + NULL, + 'corr-tts', + 'user', + '{"threadId":"thread-legacy","messageId":"msg-1","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","contextWindow":"1m"}},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-already-array', + 'thread', + 'thread-legacy', + 4, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-taa', + NULL, + 'corr-taa', + 'user', + '{"threadId":"thread-already-array","projectId":"project-legacy","title":"Already Array","modelSelection":{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"medium"}]},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-activity-append', + 'thread', + 'thread-legacy', + 5, + 'thread.activity-appended', + '2026-01-01T00:00:00.000Z', + 'cmd-aa', + NULL, + 'corr-aa', + 'user', + '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}', + '{}' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 26 }); + + // Projection projects + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual( + projectRows.map((row) => ({ + projectId: row.projectId, + selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, + })), + [ + { + projectId: "project-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }, + { + projectId: "project-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }, + }, + { + projectId: "project-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + { projectId: "project-null-selection", selection: null }, + ], + ); + + // Projection threads + const threadRows = yield* sql<{ + readonly threadId: string; + readonly modelSelection: string | null; + }>` + SELECT + thread_id AS "threadId", + model_selection_json AS "modelSelection" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual( + threadRows.map((row) => ({ + threadId: row.threadId, + selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, + })), + [ + { + threadId: "thread-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }, + }, + { + threadId: "thread-drop-garbage", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + // Only the scalar string survives; nested object, whitespace + // string, and null are dropped. + options: [{ id: "effort", value: "high" }], + }, + }, + { + threadId: "thread-empty-options", + selection: { provider: "codex", model: "gpt-5.4", options: [] }, + }, + { + threadId: "thread-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], + }, + }, + { + threadId: "thread-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + ], + ); + + // Orchestration events + const eventRows = yield* sql<{ + readonly eventId: string; + readonly payloadJson: string; + }>` + SELECT event_id AS "eventId", payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY event_id + `; + + const payloads = Object.fromEntries( + eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), + ); + + assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); + + assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }); + + assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); + + assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + ], + }); + + assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }); + + assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }); + + // Already-array records are left untouched. + assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + }); + + // Events with no modelSelection at all are untouched. + assert.isUndefined(payloads["event-activity-append"].modelSelection); + assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts new file mode 100644 index 00000000000..15c08debf64 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts @@ -0,0 +1,138 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from + * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the + * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`). + * + * Migration 016 introduced `modelSelection` with `options` stored as a + * per-provider object. Later the schema was reshaped so that options are a + * generic `Array<{ id, value }>` of user-selected option entries. Stored rows + * from before the reshape still have the object shape and fail to decode. + * + * For each value in the legacy object: + * - string values are kept if non-empty after trim + * - boolean values are always kept (true | false) + * - any other value type (number, null, nested object/array) is dropped, + * matching the permissive client-side normalizer in composerDraftStore. + * + * Touched storage: + * - `projection_threads.model_selection_json.options` + * - `projection_projects.default_model_selection_json.options` + * - `orchestration_events.payload_json.$.modelSelection.options` + * (thread.created | thread.meta-updated | thread.turn-start-requested) + * - `orchestration_events.payload_json.$.defaultModelSelection.options` + * (project.created | project.meta-updated) + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET model_selection_json = json_set( + model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE model_selection_json IS NOT NULL + AND json_type(model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = json_set( + default_model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(default_model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE default_model_selection_json IS NOT NULL + AND json_type(default_model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.modelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.modelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ( + 'thread.created', + 'thread.meta-updated', + 'thread.turn-start-requested' + ) + AND json_type(payload_json, '$.modelSelection.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.defaultModelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection.options') = 'object' + `; +}); From 78fa16c7e76379dc3f7a26bf65a6779fcd56a10b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Apr 2026 00:21:47 +0000 Subject: [PATCH 3/7] fix: extract shared resolvePromptInjectedEffort to fix overly broad descriptor search The prompt injection descriptor lookup in both ClaudeAdapter.ts and ChatView.tsx used find() across four candidate descriptor IDs, which could match the wrong descriptor if multiple select descriptors had promptInjectedValues. Replace the fragile pattern with a shared helper that iterates all descriptors and checks each one's promptInjectedValues list independently. --- .../src/provider/Layers/ClaudeAdapter.ts | 24 +++---------------- apps/web/src/components/ChatView.tsx | 21 +++------------- packages/shared/src/model.ts | 23 ++++++++++++++++++ 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6c21c0cef18..8f3aae049b7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -43,9 +43,9 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getProviderOptionDescriptors, getModelSelectionOptionValue, - trimOrNull, + getProviderOptionDescriptors, + resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { Cause, @@ -569,25 +569,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); - // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // Normal Claude effort resolution strips prompt-injected values back to the model default, - // so prompt formatting must look at the raw selection value directly. - const trimmedEffort = trimOrNull(rawEffort); - const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "effort" || - descriptor.id === "reasoningEffort" || - descriptor.id === "reasoning" || - descriptor.id === "variant") && - (descriptor.promptInjectedValues?.length ?? 0) > 0, - ); - const promptEffort = - trimmedEffort && - promptInjectedDescriptor?.type === "select" && - promptInjectedDescriptor.promptInjectedValues?.includes(trimmedEffort) - ? trimmedEffort - : null; + const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 354802b5940..43730ebbf93 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,7 @@ import { import { applyClaudePromptEffortPrefix, createModelSelection, - getProviderOptionDescriptors, + resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; @@ -309,23 +309,8 @@ function formatOutgoingPrompt(params: { text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "reasoningEffort" || - descriptor.id === "effort" || - descriptor.id === "reasoning" || - descriptor.id === "variant") && - (descriptor.promptInjectedValues?.length ?? 0) > 0, - ); - if ( - params.effort && - promptInjectedDescriptor?.type === "select" && - promptInjectedDescriptor.promptInjectedValues?.includes(params.effort) - ) { - return applyClaudePromptEffortPrefix(params.text, params.effort); - } - return params.text; + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 03169fdcc1d..dbdd43d0eb8 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -292,6 +292,29 @@ export function createModelSelection( } as ModelSelection; } +/** + * Returns the effort value if it is a prompt-injected value according to + * any select descriptor in the given capabilities, or null otherwise. + * + * Unlike a single `find`, this checks every descriptor so that the + * correct descriptor's `promptInjectedValues` list is consulted even when + * multiple select descriptors exist. + */ +export function resolvePromptInjectedEffort( + caps: ModelCapabilities, + rawEffort: string | null | undefined, +): string | null { + const trimmed = trimOrNull(rawEffort); + if (!trimmed) return null; + const descriptors = getProviderOptionDescriptors({ caps }); + for (const descriptor of descriptors) { + if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) { + return trimmed; + } + } + return null; +} + export function applyClaudePromptEffortPrefix( text: string, effort: string | null | undefined, From 909f253192a1c712fabc3670bbbdf4a8deabc276 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 21 Apr 2026 18:47:21 -0700 Subject: [PATCH 4/7] Address CI format failure and Cursor Bugbot review comments - Fix Format CI: apply oxfmt to 026_CanonicalizeModelSelectionOptions.test.ts - Add shared normalizeClaudeCliEffort() in ClaudeProvider that maps the Opus 4.7 capability "xhigh" to the CLI-accepted "max" and filters "ultrathink" (prompt-prefix mode). Use it in ClaudeTextGeneration before passing --effort, fixing the medium-severity bug where resolveClaudeEffort's raw output could land on the CLI unmapped. - Re-point ClaudeAdapter's getEffectiveClaudeAgentEffort at the shared normalizer so SDK and CLI paths can't drift. - Simplify ClaudeTextGeneration effort resolution: read the raw selection via getModelSelectionOptionValue (matching ClaudeAdapter's pattern) instead of going through descriptors + currentValue, then passing to resolveClaudeEffort a second time. Resolves the low-severity "double effort resolution / misleading rawEffortValue naming" comment. - Remove unused fakeOpenCodeSnapshot fixture from ProviderRegistry.test. --- .../src/git/Layers/ClaudeTextGeneration.ts | 14 +- ..._CanonicalizeModelSelectionOptions.test.ts | 290 +++++++++--------- .../src/provider/Layers/ClaudeAdapter.ts | 13 +- .../src/provider/Layers/ClaudeProvider.ts | 20 ++ .../provider/Layers/ProviderRegistry.test.ts | 14 - 5 files changed, 176 insertions(+), 175 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index e36d80b748e..a6ab8306248 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,9 +28,10 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { getProviderOptionCurrentValue, getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { getModelSelectionOptionValue, getProviderOptionDescriptors } from "@t3tools/shared/model"; import { getClaudeModelCapabilities, + normalizeClaudeCliEffort, resolveClaudeApiModelId, resolveClaudeEffort, } from "../../provider/Layers/ClaudeProvider.ts"; @@ -93,9 +94,12 @@ const makeClaudeTextGeneration = Effect.gen(function* () { selections: modelSelection.options, }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortValue = getProviderOptionCurrentValue(findDescriptor("effort")); - const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : undefined; - const resolvedEffort = resolveClaudeEffort(caps, rawEffort); + const rawEffortSelection = getModelSelectionOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort( + caps, + typeof rawEffortSelection === "string" ? rawEffortSelection : undefined, + ); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); const thinking = @@ -123,7 +127,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { jsonSchemaStr, "--model", resolveClaudeApiModelId(modelSelection), - ...(resolvedEffort ? ["--effort", resolvedEffort] : []), + ...(cliEffort ? ["--effort", cliEffort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", ], diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts index 527df8fdfe0..ffc42521c90 100644 --- a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -8,15 +8,13 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); layer("026_CanonicalizeModelSelectionOptions", (it) => { - it.effect( - "converts legacy object-shape options into array-shape on projections and events", - () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; + it.effect("converts legacy object-shape options into array-shape on projections and events", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 25 }); + yield* runMigrations({ toMigrationInclusive: 25 }); - yield* sql` + yield* sql` INSERT INTO projection_projects ( project_id, title, @@ -70,7 +68,7 @@ layer("026_CanonicalizeModelSelectionOptions", (it) => { ) `; - yield* sql` + yield* sql` INSERT INTO projection_threads ( thread_id, project_id, @@ -148,7 +146,7 @@ layer("026_CanonicalizeModelSelectionOptions", (it) => { ) `; - yield* sql` + yield* sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, @@ -278,175 +276,175 @@ layer("026_CanonicalizeModelSelectionOptions", (it) => { ) `; - yield* runMigrations({ toMigrationInclusive: 26 }); + yield* runMigrations({ toMigrationInclusive: 26 }); - // Projection projects - const projectRows = yield* sql<{ - readonly projectId: string; - readonly defaultModelSelection: string | null; - }>` + // Projection projects + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` SELECT project_id AS "projectId", default_model_selection_json AS "defaultModelSelection" FROM projection_projects ORDER BY project_id `; - assert.deepStrictEqual( - projectRows.map((row) => ({ - projectId: row.projectId, - selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, - })), - [ - { - projectId: "project-already-array", - selection: { - provider: "codex", - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "high" }], - }, + assert.deepStrictEqual( + projectRows.map((row) => ({ + projectId: row.projectId, + selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, + })), + [ + { + projectId: "project-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], }, - { - projectId: "project-legacy", - selection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - }, + }, + { + projectId: "project-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], }, - { - projectId: "project-no-options", - selection: { provider: "codex", model: "gpt-5.4" }, - }, - { projectId: "project-null-selection", selection: null }, - ], - ); + }, + { + projectId: "project-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + { projectId: "project-null-selection", selection: null }, + ], + ); - // Projection threads - const threadRows = yield* sql<{ - readonly threadId: string; - readonly modelSelection: string | null; - }>` + // Projection threads + const threadRows = yield* sql<{ + readonly threadId: string; + readonly modelSelection: string | null; + }>` SELECT thread_id AS "threadId", model_selection_json AS "modelSelection" FROM projection_threads ORDER BY thread_id `; - assert.deepStrictEqual( - threadRows.map((row) => ({ - threadId: row.threadId, - selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, - })), - [ - { - threadId: "thread-already-array", - selection: { - provider: "codex", - model: "gpt-5.4", - options: [{ id: "fastMode", value: true }], - }, - }, - { - threadId: "thread-drop-garbage", - selection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - // Only the scalar string survives; nested object, whitespace - // string, and null are dropped. - options: [{ id: "effort", value: "high" }], - }, - }, - { - threadId: "thread-empty-options", - selection: { provider: "codex", model: "gpt-5.4", options: [] }, + assert.deepStrictEqual( + threadRows.map((row) => ({ + threadId: row.threadId, + selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, + })), + [ + { + threadId: "thread-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], }, - { - threadId: "thread-legacy", - selection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "thinking", value: false }, - { id: "contextWindow", value: "1m" }, - ], - }, + }, + { + threadId: "thread-drop-garbage", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + // Only the scalar string survives; nested object, whitespace + // string, and null are dropped. + options: [{ id: "effort", value: "high" }], }, - { - threadId: "thread-no-options", - selection: { provider: "codex", model: "gpt-5.4" }, + }, + { + threadId: "thread-empty-options", + selection: { provider: "codex", model: "gpt-5.4", options: [] }, + }, + { + threadId: "thread-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], }, - ], - ); + }, + { + threadId: "thread-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + ], + ); - // Orchestration events - const eventRows = yield* sql<{ - readonly eventId: string; - readonly payloadJson: string; - }>` + // Orchestration events + const eventRows = yield* sql<{ + readonly eventId: string; + readonly payloadJson: string; + }>` SELECT event_id AS "eventId", payload_json AS "payloadJson" FROM orchestration_events ORDER BY event_id `; - const payloads = Object.fromEntries( - eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), - ); + const payloads = Object.fromEntries( + eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), + ); - assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - }); + assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); - assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { - provider: "codex", - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "low" }], - }); + assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }); - assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); + assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); - assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "thinking", value: false }, - ], - }); + assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + ], + }); - assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { - provider: "codex", - model: "gpt-5.4", - options: [{ id: "fastMode", value: true }], - }); + assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }); - assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "high" }, - { id: "contextWindow", value: "1m" }, - ], - }); + assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }); - // Already-array records are left untouched. - assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { - provider: "codex", - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "medium" }], - }); + // Already-array records are left untouched. + assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + }); - // Events with no modelSelection at all are untouched. - assert.isUndefined(payloads["event-activity-append"].modelSelection); - assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); - }), + // Events with no modelSelection at all are untouched. + assert.isUndefined(payloads["event-activity-append"].modelSelection); + assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); + }), ); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8f3aae049b7..3b8dce0060d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -67,6 +67,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities, + normalizeClaudeCliEffort, resolveClaudeApiModelId, resolveClaudeEffort, } from "./ClaudeProvider.ts"; @@ -220,16 +221,8 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray } function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { - if (!effort) { - return null; - } - if (effort === "ultrathink") { - return null; - } - if (effort === "xhigh") { - return "max"; - } - return effort as ClaudeSdkEffort; + const normalized = normalizeClaudeCliEffort(effort); + return normalized ? (normalized as ClaudeSdkEffort) : null; } function isClaudeInterruptedMessage(message: string): boolean { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 3ff5407af37..58fff762665 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -221,6 +221,26 @@ export function resolveClaudeEffort( return typeof value === "string" ? value : undefined; } +/** + * Normalize a resolved Claude effort value into one suitable for the Claude + * CLI's `--effort` flag. + * + * Mirrors the mapping used when invoking the Claude Agent SDK + * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): the Opus 4.7 + * capability `"xhigh"` is rewritten to the accepted CLI value `"max"`, and + * `"ultrathink"` is filtered out because it is a prompt-prefix mode rather + * than a CLI-effort value. Returns `undefined` when no flag should be passed. + */ +export function normalizeClaudeCliEffort(effort: string | null | undefined): string | undefined { + if (!effort || effort === "ultrathink") { + return undefined; + } + if (effort === "xhigh") { + return "max"; + } + return effort; +} + export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { switch (getModelSelectionOptionValue(modelSelection, "contextWindow")) { case "1m": diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 183a7d0c1a7..1df9da67743 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -54,20 +54,6 @@ function booleanDescriptor(id: string, label: string) { }; } -const fakeOpenCodeSnapshot: ServerProvider = { - provider: "opencode", - status: "warning", - enabled: true, - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: null, - models: [], - slashCommands: [], - skills: [], - message: "OpenCode test stub", -}; - function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), From 513c99ba5ff48915b6dede7ef65dd6ef7b410d8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 21 Apr 2026 19:07:11 -0700 Subject: [PATCH 5/7] =?UTF-8?q?Fix=20two=20sticky-codex=20browser=20tests?= =?UTF-8?q?=20after=20object=E2=86=92array=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-refactor test asserted `toMatchObject({ options: { fastMode: true } })` against an object-shaped `options`, which tolerated extra keys like `reasoningEffort`. When the refactor converted `options` to a generic array of `{ id, value }` selections the assertion was mechanically rewritten, but `toMatchObject` compares arrays strictly (length + positional match), so the extra sticky `reasoningEffort` entry now fails the match even though runtime behaviour is unchanged. Wrap the `options` array in `expect.arrayContaining([...])` so the assertion keeps its original intent — "verify the sticky `fastMode` trait carries into the new draft" — while allowing other sticky entries that previously flew under the object-match radar. No production code change; only ChatView.browser.tsx assertions adjusted. --- apps/web/src/components/ChatView.browser.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5ff6a7887f7..f5cc79b88a8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3807,9 +3807,17 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const newDraftId = draftIdFromPath(newThreadPath); + // `toMatchObject` matches objects loosely (extras ignored) but compares + // arrays strictly, so wrap `options` in `arrayContaining` to keep the + // assertion focused on sticky `fastMode` carrying over without asserting + // on exactly which other options are preserved. expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { - codex: createModelSelection("codex", "gpt-5.3-codex", [{ id: "fastMode", value: true }]), + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: expect.arrayContaining([{ id: "fastMode", value: true }]), + }, }, activeProvider: "codex", }); @@ -3924,9 +3932,16 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const draftId = draftIdFromPath(threadPath); + // See the note on the sibling sticky-codex test: arrays match strictly + // under `toMatchObject`, so use `arrayContaining` to keep the assertion + // scoped to the sticky trait (`fastMode`) that must carry over. expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { - codex: createModelSelection("codex", "gpt-5.3-codex", [{ id: "fastMode", value: true }]), + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: expect.arrayContaining([{ id: "fastMode", value: true }]), + }, }, activeProvider: "codex", }); From 8b2ab13ed999a5dd3a5e617dc52647191aea42ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 21 Apr 2026 20:22:41 -0700 Subject: [PATCH 6/7] Refactor model selection helpers by option type - Add typed string/boolean selection accessors - Update provider adapters to use typed helpers - Cover selection helper behavior with tests Co-authored-by: codex --- .../src/git/Layers/ClaudeTextGeneration.ts | 12 ++++---- .../src/git/Layers/CodexTextGeneration.ts | 9 ++++-- .../src/git/Layers/OpenCodeTextGeneration.ts | 17 +++++------ .../src/provider/Layers/ClaudeAdapter.ts | 24 +++++++-------- .../src/provider/Layers/ClaudeProvider.ts | 4 +-- .../src/provider/Layers/CodexAdapter.ts | 28 +++++++++-------- .../src/provider/Layers/CursorProvider.ts | 30 ++++++------------- .../src/provider/Layers/OpenCodeAdapter.ts | 15 ++++------ packages/shared/src/model.test.ts | 22 ++++++++++++++ packages/shared/src/model.ts | 30 +++++++++++++++++++ 10 files changed, 116 insertions(+), 75 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index a6ab8306248..8175dc54b22 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,7 +28,10 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { getModelSelectionOptionValue, getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import { getClaudeModelCapabilities, normalizeClaudeCliEffort, @@ -94,11 +97,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () { selections: modelSelection.options, }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortSelection = getModelSelectionOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort( - caps, - typeof rawEffortSelection === "string" ? rawEffortSelection : undefined, - ); + const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); const cliEffort = normalizeClaudeCliEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index abadd5a4bb0..ee1e39d0fa0 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -30,7 +30,10 @@ import { toJsonSchemaObject, } from "../Utils.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getModelSelectionOptionValue } from "@t3tools/shared/model"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -156,7 +159,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const reasoningEffort = - (getModelSelectionOptionValue(modelSelection, "reasoningEffort") as string | undefined) ?? + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( codexSettings?.binaryPath || "codex", @@ -170,7 +173,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(getModelSelectionOptionValue(modelSelection, "fastMode") === true + ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true ? ["--config", `service_tier="fast"`] : []), "--output-schema", diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index 622a46dc281..d9ebb094e72 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -7,7 +7,7 @@ import { type OpenCodeModelSelection, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { getModelSelectionOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -321,18 +321,17 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { if (!session.data) { throw new Error("OpenCode session.create returned no session payload."); } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue( + input.modelSelection, + "variant", + ); const result = await client.session.prompt({ sessionID: session.data.id, model: parsedModel, - ...(typeof getModelSelectionOptionValue(input.modelSelection, "agent") === "string" - ? { agent: getModelSelectionOptionValue(input.modelSelection, "agent") as string } - : {}), - ...(typeof getModelSelectionOptionValue(input.modelSelection, "variant") === "string" - ? { - variant: getModelSelectionOptionValue(input.modelSelection, "variant") as string, - } - : {}), + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), parts: [{ type: "text", text: input.prompt }, ...fileParts], }); const info = result.data?.info; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3b8dce0060d..03dfffa0426 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -43,7 +43,8 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getModelSelectionOptionValue, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; @@ -553,11 +554,10 @@ const CLAUDE_SETTING_SOURCES = [ ] as const satisfies ReadonlyArray; function buildPromptText(input: ProviderSendTurnInput): string { - const rawEffortValue = + const rawEffort = input.modelSelection?.provider === "claudeAgent" - ? getModelSelectionOptionValue(input.modelSelection, "effort") + ? getModelSelectionStringOptionValue(input.modelSelection, "effort") : null; - const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : null; const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); @@ -2824,9 +2824,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const caps = getClaudeModelCapabilities(modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const rawEffort = getModelSelectionOptionValue(modelSelection, "effort"); - const effort = - resolveClaudeEffort(caps, typeof rawEffort === "string" ? rawEffort : undefined) ?? null; + const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); + const effort = resolveClaudeEffort(caps, rawEffort) ?? null; const fastModeSupported = descriptors.some( (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", ); @@ -2834,12 +2833,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", ); const fastMode = - getModelSelectionOptionValue(modelSelection, "fastMode") === true && fastModeSupported; - const thinking = - typeof getModelSelectionOptionValue(modelSelection, "thinking") === "boolean" && - thinkingSupported - ? (getModelSelectionOptionValue(modelSelection, "thinking") as boolean) - : undefined; + getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && + fastModeSupported; + const thinking = thinkingSupported + ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") + : undefined; const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 58fff762665..8d0faab3972 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -13,7 +13,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { createModelCapabilities, - getModelSelectionOptionValue, + getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -242,7 +242,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str } export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (getModelSelectionOptionValue(modelSelection, "contextWindow")) { + switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { case "1m": return `${modelSelection.model}[1m]`; default: diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cb3f8c464c9..5c824dc7309 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,7 +26,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { getModelSelectionOptionValue } from "@t3tools/shared/model"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; import { ProviderAdapterRequestError, @@ -1375,7 +1378,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(input.modelSelection?.provider === "codex" && - getModelSelectionOptionValue(input.modelSelection, "fastMode") === true + getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), }; @@ -1488,25 +1491,26 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const session = yield* requireSession(input.threadId); + const reasoningEffort = + input.modelSelection?.provider === "codex" + ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") + : undefined; + const fastMode = + input.modelSelection?.provider === "codex" + ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") + : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), - ...(input.modelSelection?.provider === "codex" && - typeof getModelSelectionOptionValue(input.modelSelection, "reasoningEffort") === "string" + ...(reasoningEffort ? { - effort: getModelSelectionOptionValue( - input.modelSelection, - "reasoningEffort", - ) as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, } : {}), - ...(input.modelSelection?.provider === "codex" && - getModelSelectionOptionValue(input.modelSelection, "fastMode") === true - ? { serviceTier: "fast" } - : {}), + ...(fastMode === true ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e07990a2fd0..4225023f7d8 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -15,7 +15,11 @@ import type { import type * as EffectAcpSchema from "effect-acp/schema"; import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { + createModelCapabilities, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; import { buildBooleanOptionDescriptor, @@ -453,22 +457,6 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; } -function getStringSelection( - selections: ReadonlyArray | null | undefined, - id: string, -): string | undefined { - const value = selections?.find((selection) => selection.id === id)?.value; - return typeof value === "string" ? value : undefined; -} - -function getBooleanSelection( - selections: ReadonlyArray | null | undefined, - id: string, -): boolean | undefined { - const value = selections?.find((selection) => selection.id === id)?.value; - return typeof value === "boolean" ? value : undefined; -} - export function resolveCursorAcpConfigUpdates( configOptions: ReadonlyArray | null | undefined, selections: ReadonlyArray | null | undefined, @@ -481,7 +469,7 @@ export function resolveCursorAcpConfigUpdates( const reasoningOption = findCursorEffortConfigOption(configOptions); const requestedReasoning = normalizeCursorReasoningValue( - getStringSelection(selections, "reasoning"), + getProviderOptionStringSelectionValue(selections, "reasoning"), ); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { @@ -497,7 +485,7 @@ export function resolveCursorAcpConfigUpdates( const contextOption = configOptions.find( (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - const requestedContextWindow = getStringSelection(selections, "contextWindow"); + const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); if (contextOption && requestedContextWindow) { const value = findCursorSelectOptionValue( contextOption, @@ -515,7 +503,7 @@ export function resolveCursorAcpConfigUpdates( const fastOption = configOptions.find( (option) => option.category === "model_config" && isCursorFastConfigOption(option), ); - const requestedFastMode = getBooleanSelection(selections, "fastMode"); + const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); if (fastOption && typeof requestedFastMode === "boolean") { const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); if (value !== undefined) { @@ -526,7 +514,7 @@ export function resolveCursorAcpConfigUpdates( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); - const requestedThinking = getBooleanSelection(selections, "thinking"); + const requestedThinking = getProviderOptionBooleanSelectionValue(selections, "thinking"); if (thinkingOption && typeof requestedThinking === "boolean") { const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); if (value !== undefined) { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 7719e660dd5..aa504d5b969 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -13,7 +13,7 @@ import { } from "@t3tools/contracts"; import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; -import { getModelSelectionOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1147,19 +1147,16 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const agent = input.modelSelection?.provider === PROVIDER - ? getModelSelectionOptionValue(input.modelSelection, "agent") + ? getModelSelectionStringOptionValue(input.modelSelection, "agent") : undefined; const variant = input.modelSelection?.provider === PROVIDER - ? getModelSelectionOptionValue(input.modelSelection, "variant") + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") : undefined; - const selectedAgent = typeof agent === "string" ? agent : undefined; - const selectedVariant = typeof variant === "string" ? variant : undefined; context.activeTurnId = turnId; - context.activeAgent = - selectedAgent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = selectedVariant; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; updateProviderSession( context, { @@ -1175,7 +1172,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { type: "turn.started", payload: { model: modelSelection?.model ?? context.session.model, - ...(selectedVariant ? { effort: selectedVariant } : {}), + ...(variant ? { effort: variant } : {}), }, }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8c8ea3a30d3..242e7982234 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -6,7 +6,11 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, getProviderOptionDescriptors, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, isClaudeUltrathinkPrompt, normalizeModelSlug, resolveModelSlugForProvider, @@ -191,4 +195,22 @@ describe("descriptor helpers", () => { ], }); }); + + it("reads typed option selection values", () => { + const selection = createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); + + expect(getProviderOptionStringSelectionValue(selection.options, "reasoningEffort")).toBe( + "high", + ); + expect(getProviderOptionStringSelectionValue(selection.options, "fastMode")).toBeUndefined(); + expect(getProviderOptionBooleanSelectionValue(selection.options, "fastMode")).toBe(true); + expect( + getProviderOptionBooleanSelectionValue(selection.options, "reasoningEffort"), + ).toBeUndefined(); + expect(getModelSelectionStringOptionValue(selection, "reasoningEffort")).toBe("high"); + expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true); + }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index dbdd43d0eb8..4f61fc33e83 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -36,6 +36,22 @@ export function getProviderOptionSelectionValue( return getRawSelectionValueById(selections, id); } +export function getProviderOptionStringSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): string | undefined { + const value = getProviderOptionSelectionValue(selections, id); + return typeof value === "string" ? value : undefined; +} + +export function getProviderOptionBooleanSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): boolean | undefined { + const value = getProviderOptionSelectionValue(selections, id); + return typeof value === "boolean" ? value : undefined; +} + export function getModelSelectionOptionValue( modelSelection: ModelSelection | null | undefined, id: string, @@ -43,6 +59,20 @@ export function getModelSelectionOptionValue( return getProviderOptionSelectionValue(modelSelection?.options, id); } +export function getModelSelectionStringOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): string | undefined { + return getProviderOptionStringSelectionValue(modelSelection?.options, id); +} + +export function getModelSelectionBooleanOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): boolean | undefined { + return getProviderOptionBooleanSelectionValue(modelSelection?.options, id); +} + function resolveDescriptorChoiceValue( descriptor: Extract, raw: string | null | undefined, From bf23358ac7e685777788c9ddadea7f28300e8ddc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Apr 2026 09:27:54 -0400 Subject: [PATCH 7/7] Accept legacy model option objects - Decode old object-shaped `options` into canonical arrays - Cover settings and orchestration round trips --- apps/server/src/serverSettings.test.ts | 24 +++++- packages/contracts/src/model.ts | 72 +++++++++++++++++- packages/contracts/src/orchestration.test.ts | 78 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 10 +-- packages/contracts/src/settings.ts | 13 ++-- 5 files changed, 185 insertions(+), 12 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d2b4eedf151..55f598054be 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; @@ -41,6 +41,28 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); + it.effect( + "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", + () => + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(ServerSettings); + + const decoded = decode({ + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + options: { reasoningEffort: "low" }, + }, + }); + + assert.deepEqual(decoded.textGenerationModelSelection, { + provider: "codex", + model: "gpt-5.4-mini", + options: [{ id: "reasoningEffort", value: "low" }], + }); + }), + ); + it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 88ebfb71230..92de00bea3b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Effect, Schema, SchemaTransformation } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import type { ProviderKind } from "./orchestration.ts"; @@ -50,6 +50,76 @@ export const ProviderOptionSelection = Schema.Struct({ }); export type ProviderOptionSelection = typeof ProviderOptionSelection.Type; +/** + * Legacy on-disk shape for provider option selections, kept readable by the + * decoder so we can tolerate stored data written before the v3 array shape. + * + * Persisted historically as `{ effort: "max", fastMode: true, ... }` inside + * `modelSelection.options`. Migration 026 rewrites stored rows to the + * canonical array shape, but we still see the legacy form in: + * - `settings.json` files from older client builds, + * - SQLite databases that have not yet run migration 026, + * - any future regression that re-introduces the legacy shape. + */ +const LegacyProviderOptionSelectionsObject = Schema.Record(Schema.String, Schema.Unknown); + +const ProviderOptionSelectionsFromLegacyObject = LegacyProviderOptionSelectionsObject.pipe( + Schema.decodeTo( + Schema.Array(ProviderOptionSelection), + SchemaTransformation.transformOrFail({ + decode: (record) => Effect.succeed(coerceLegacyOptionsObjectToArray(record)), + encode: (selections) => Effect.succeed(canonicalSelectionsToLegacyObject(selections)), + }), + ), +); + +/** + * Schema for the `options` field of every `ModelSelection` variant. + * + * Accepts both: + * - the canonical array shape `Array<{ id, value }>` (preferred), and + * - the legacy object shape `Record` from + * pre-migration data. + * + * Always normalizes to the canonical array on decode and re-encodes as the + * canonical array, so any legacy storage gets cleaned up the next time the + * containing record is written back. + */ +export const ProviderOptionSelections = Schema.Union([ + Schema.Array(ProviderOptionSelection), + ProviderOptionSelectionsFromLegacyObject, +]); +export type ProviderOptionSelections = typeof ProviderOptionSelections.Type; + +function coerceLegacyOptionsObjectToArray( + record: Record, +): ReadonlyArray { + const entries: Array = []; + for (const [rawKey, rawValue] of Object.entries(record)) { + const id = typeof rawKey === "string" ? rawKey.trim() : ""; + if (!id) continue; + if (typeof rawValue === "string") { + const trimmed = rawValue.trim(); + if (trimmed) entries.push({ id, value: trimmed }); + } else if (typeof rawValue === "boolean") { + entries.push({ id, value: rawValue }); + } + // Drop anything else (numbers, null, nested objects/arrays) to match the + // permissive normalization performed by migration 026. + } + return entries; +} + +function canonicalSelectionsToLegacyObject( + selections: ReadonlyArray, +): Record { + const out: Record = {}; + for (const { id, value } of selections) { + out[id] = value; + } + return out; +} + export const ModelCapabilities = Schema.Struct({ optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 18ba2d165d2..190e09aa631 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -384,6 +384,84 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("normalizes legacy object-shaped modelSelection.options on decode", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadCreatedPayload({ + threadId: "thread-1", + projectId: "project-1", + title: "Legacy options thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + // Falsy/garbage entries are dropped, matching migration 026. + emptyStr: " ", + nullish: null, + nested: { foo: 1 }, + }, + }, + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(parsed.modelSelection.provider, "claudeAgent"); + assert.deepStrictEqual(parsed.modelSelection.options, [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]); + }), +); + +it.effect("normalizes legacy object-shaped defaultModelSelection.options on decode", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreatedPayload({ + projectId: "project-1", + title: "Legacy default project", + workspaceRoot: "/tmp/legacy", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low" }, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(parsed.defaultModelSelection?.options, [ + { id: "reasoningEffort", value: "low" }, + ]); + }), +); + +it.effect( + "normalizes legacy object-shaped options on decode and re-encodes as canonical array", + () => + Effect.gen(function* () { + const decoded = yield* decodeThreadCreatedPayload({ + threadId: "thread-1", + projectId: "project-1", + title: "Round trip thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { fastMode: true }, + }, + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const encoded = yield* Schema.encodeEffect(ThreadCreatedPayload)(decoded); + assert.deepStrictEqual(encoded.modelSelection.options, [{ id: "fastMode", value: true }]); + }), +); + it.effect("accepts a title seed in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 30bed60a479..c201d6c82a3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { ProviderOptionSelection } from "./model.ts"; +import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, @@ -46,27 +46,27 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ provider: Schema.Literal("codex"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const CursorModelSelection = Schema.Struct({ provider: Schema.Literal("cursor"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }); export type CursorModelSelection = typeof CursorModelSelection.Type; export const OpenCodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }); export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e93de82642..6301364f337 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,10 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; -import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProviderOptionSelection } from "./model.ts"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProviderOptionSelections, +} from "./model.ts"; import { ModelSelection, ProviderKind } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -168,22 +171,22 @@ const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("claudeAgent")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("cursor")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("opencode")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(Schema.Array(ProviderOptionSelection)), + options: Schema.optionalKey(ProviderOptionSelections), }), ]);