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..8175dc54b22 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,10 +28,17 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + 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 +91,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 rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); + 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 +127,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { jsonSchemaStr, "--model", resolveClaudeApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(cliEffort ? ["--effort", cliEffort] : []), ...(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..ee1e39d0fa0 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -30,6 +30,10 @@ import { toJsonSchemaObject, } from "../Utils.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -155,7 +159,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const reasoningEffort = - modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( codexSettings?.binaryPath || "codex", [ @@ -168,7 +173,9 @@ const makeCodexTextGeneration = Effect.gen(function* () { modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []), + ...(getModelSelectionBooleanOptionValue(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..d9ebb094e72 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 { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -320,16 +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, - ...(input.modelSelection.options?.agent - ? { agent: input.modelSelection.options.agent } - : {}), - ...(input.modelSelection.options?.variant - ? { variant: input.modelSelection.options.variant } - : {}), + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), parts: [{ type: "text", text: input.prompt }, ...fileParts], }); const info = result.data?.info; 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/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..ffc42521c90 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -0,0 +1,450 @@ +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' + `; +}); 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..03dfffa0426 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,9 +40,14 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeAgentEffort, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + resolvePromptInjectedEffort, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -61,7 +66,12 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -211,19 +221,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeAgentEffort( - effort: ClaudeAgentEffort | null | undefined, -): ClaudeSdkEffort | null { - if (!effort) { - return null; - } - if (effort === "ultrathink") { - return null; - } - if (effort === "xhigh") { - return "max"; - } - return effort; +function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { + const normalized = normalizeClaudeCliEffort(effort); + return normalized ? (normalized as ClaudeSdkEffort) : null; } function isClaudeInterruptedMessage(message: string): boolean { @@ -555,16 +555,14 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + input.modelSelection?.provider === "claudeAgent" + ? getModelSelectionStringOptionValue(input.modelSelection, "effort") + : 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. - const trimmedEffort = trimOrNull(rawEffort); - const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -2824,14 +2822,22 @@ 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 thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking - : undefined; + const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); + const effort = resolveClaudeEffort(caps, rawEffort) ?? 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 = + 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 60836210b5d..f41082bbbab 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, + getModelSelectionStringOptionValue, + 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, AUTH_PROBE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, @@ -35,108 +43,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" }, + 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" }, + ], + }), ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { 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" }, - ], - 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", 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" }, + ], + }), ], - 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", + }), + ], + }), }, ]; @@ -166,8 +209,41 @@ 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; +} + +/** + * 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 (modelSelection.options?.contextWindow) { + switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { case "1m": return `${modelSelection.model}[1m]`; default: @@ -579,6 +655,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models: allModels, @@ -601,6 +678,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = versionProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -619,6 +697,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, @@ -639,6 +718,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const detail = detailFromResult(version); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -703,6 +783,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = authProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -723,6 +804,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(authProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -741,6 +823,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -774,6 +857,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models, @@ -789,6 +873,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..5c824dc7309 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,6 +26,11 @@ 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 { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; + import { ProviderAdapterRequestError, ProviderAdapterProcessError, @@ -1372,7 +1377,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" && + getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), }; @@ -1485,19 +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" && - input.modelSelection.options?.reasoningEffort !== undefined - ? { effort: input.modelSelection.options.reasoningEffort } - : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode - ? { serviceTier: "fast" } + ...(reasoningEffort + ? { + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } : {}), + ...(fastMode === true ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1e48847c52c..915609ca219 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"; @@ -34,6 +36,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; @@ -87,17 +93,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 => { @@ -292,6 +325,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models, @@ -308,6 +342,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: true, checkedAt, models, @@ -375,6 +410,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models: emptyModels, @@ -401,6 +437,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, @@ -420,6 +457,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, @@ -439,6 +477,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..4225023f7d8 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,15 @@ 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, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, collectStreamAsString, isCommandMissingCause, @@ -29,13 +36,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 +63,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models, @@ -70,6 +79,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: true, checkedAt, models, @@ -198,6 +208,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 +283,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 +360,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( @@ -387,7 +459,7 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s 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 +468,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( + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { const normalizedValue = normalizeCursorReasoningValue(option.value); @@ -411,14 +485,15 @@ export function resolveCursorAcpConfigUpdates( const contextOption = configOptions.find( (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - if (contextOption && modelOptions?.contextWindow) { + const requestedContextWindow = getProviderOptionStringSelectionValue(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 +503,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 = getProviderOptionBooleanSelectionValue(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); if (value !== undefined) { updates.push({ configId: fastOption.id, value }); } @@ -438,8 +514,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 = getProviderOptionBooleanSelectionValue(selections, "thinking"); + if (thinkingOption && typeof requestedThinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); if (value !== undefined) { updates.push({ configId: thinkingOption.id, value }); } @@ -610,6 +687,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 +1040,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models: fallbackModels, @@ -985,6 +1064,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 +1083,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 +1106,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..aa504d5b969 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 { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1146,11 +1147,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const agent = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.agent + ? getModelSelectionStringOptionValue(input.modelSelection, "agent") : undefined; const variant = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.variant + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") : undefined; context.activeTurnId = turnId; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 4a43c37b686..f32fd6f49e2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -176,14 +176,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 586bc3d6f48..b94840015ee 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"; @@ -25,6 +27,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; const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ @@ -159,13 +165,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; @@ -174,27 +176,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 { @@ -242,6 +263,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server if (!openCodeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models, @@ -260,6 +282,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, @@ -296,6 +319,7 @@ export const OpenCodeProviderLive = Layer.effect( }); return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: input.settings.enabled, checkedAt, models: providerModelsFromSettings( @@ -317,6 +341,7 @@ export const OpenCodeProviderLive = Layer.effect( if (!input.settings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models: providerModelsFromSettings( @@ -368,6 +393,7 @@ export const OpenCodeProviderLive = Layer.effect( if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: input.settings.enabled, checkedAt, models: providerModelsFromSettings( @@ -433,6 +459,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 8fe28351a41..1df9da67743 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,30 @@ 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, + }; +} + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -88,16 +113,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 +354,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 +393,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 +415,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 +627,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 fcea249e6ef..f1f03074852 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -30,6 +30,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(); @@ -129,8 +135,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; @@ -140,6 +190,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..55f598054be 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 { 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"; import { ServerConfig } from "./config.ts"; @@ -28,22 +29,40 @@ 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 }], }, }, ); }), ); + 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; @@ -62,10 +81,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 +99,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }); @@ -94,14 +115,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 +134,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 +146,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 +167,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..f5cc79b88a8 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", }); @@ -3810,14 +3807,16 @@ 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: { provider: "codex", model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, }, activeProvider: "codex", @@ -3830,14 +3829,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 +3860,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 +3903,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", }); @@ -3945,27 +3932,27 @@ 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: { provider: "codex", model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ 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 +3963,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 +5653,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 0c76059b6a8..3a71922706b 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, + resolvePromptInjectedEffort, +} from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -306,10 +309,8 @@ 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); - } - 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/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 b1e7cf81f6f..1e8cffd0c4a 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 = [ { @@ -1172,7 +1171,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 (
@@ -1471,11 +1472,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..92de00bea3b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,87 +1,127 @@ -import { Schema } from "effect"; +import { Effect, Schema, SchemaTransformation } 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 type ProviderReasoningEffort = - | CodexReasoningEffort - | ClaudeAgentEffort - | CursorReasoningOption; - -export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(CodexReasoningEffort), - fastMode: Schema.optional(Schema.Boolean), -}); -export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); +export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.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 const ProviderOptionChoice = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + isDefault: Schema.optional(Schema.Boolean), }); -export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export type ProviderOptionChoice = typeof ProviderOptionChoice.Type; -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 type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; +const ProviderOptionDescriptorBase = { + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), +} as const; -export const ProviderModelOptions = Schema.Struct({ - codex: Schema.optional(CodexModelOptions), - claudeAgent: Schema.optional(ClaudeModelOptions), - cursor: Schema.optional(CursorModelOptions), - opencode: Schema.optional(OpenCodeModelOptions), +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 ProviderModelOptions = typeof ProviderModelOptions.Type; +export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type; -export const EffortOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), +export const BooleanProviderOptionDescriptor = Schema.Struct({ + ...ProviderOptionDescriptorBase, + type: Schema.Literal("boolean"), + currentValue: Schema.optional(Schema.Boolean), }); -export type EffortOption = typeof EffortOption.Type; +export type BooleanProviderOptionDescriptor = typeof BooleanProviderOptionDescriptor.Type; -export const ContextWindowOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), +export const ProviderOptionDescriptor = Schema.Union([ + SelectProviderOptionDescriptor, + BooleanProviderOptionDescriptor, +]); +export type ProviderOptionDescriptor = typeof ProviderOptionDescriptor.Type; + +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; + +/** + * 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({ - 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..190e09aa631 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,19 +371,97 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => modelSelection: { provider: "codex", model: "gpt-5.3-codex", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.modelSelection?.provider, "codex"); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); + }), +); + +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: { - reasoningEffort: "high", + 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, "codex"); - assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); - assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); + + 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 087a6670901..c201d6c82a3 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 { ProviderOptionSelections } 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(ProviderOptionSelections), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(ClaudeModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const CursorModelSelection = Schema.Struct({ provider: Schema.Literal("cursor"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(CursorModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type CursorModelSelection = typeof CursorModelSelection.Type; export const OpenCodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(OpenCodeModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); 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..6301364f337 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,11 +3,8 @@ 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, + ProviderOptionSelections, } from "./model.ts"; import { ModelSelection, ProviderKind } from "./orchestration.ts"; @@ -170,50 +167,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(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("claudeAgent")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(ClaudeModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("cursor")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(CursorModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("opencode")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(OpenCodeModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), ]); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 426ceca865e..242e7982234 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,46 +3,67 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/cont import { applyClaudePromptEffortPrefix, - getDefaultContextWindow, - getDefaultEffort, - hasContextWindowOption, - hasEffortLevel, + buildProviderOptionSelectionsFromDescriptors, + createModelCapabilities, + createModelSelection, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, 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 +107,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 +129,88 @@ 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 }, + ], }); + + expect(buildProviderOptionSelectionsFromDescriptors(descriptors)).toEqual([ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); }); - it("preserves the default Claude context window explicitly", () => { + it("stores option selection arrays in model selections", () => { expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), + createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), ).toEqual({ - effort: "high", - contextWindow: "200k", + provider: "codex", + model: "gpt-5.4", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }); }); - it("omits unsupported Claude context window options", () => { + 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( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), - ).toEqual({ - thinking: true, - }); + 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 ad62debf68c..4f61fc33e83 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,215 @@ 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), + }; } -/** 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; +function getRawSelectionValueById( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + const selection = selections?.find((candidate) => candidate.id === id); + return selection?.value; } -/** - * 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 getProviderOptionSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + 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, +): string | boolean | undefined { + 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, ): 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; -} - -/** - * 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); +function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection { + return { ...selection }; } -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 +303,51 @@ 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; +} + +/** + * 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: 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), }; }