| author | |
| committer | |
| log | 8d1d699f88279066f085f95908c845334715830e |
| tree | e6932c1e2127927e48de16ad8815854e3ed33c23 |
| parent | b0b7b38da1dc4b19833d13f84eb907b1e2adfb63 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>67 files changed, 3403 insertions(+), 3828 deletions(-)
.cursor/.gitignore created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | plans/ |
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+9-12| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { it } from "@effect/vitest"; |
| 3 | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 4 | 5 | import { expect } from "vitest"; |
| 5 | 6 | |
| 6 | 7 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -199,12 +200,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 199 | 200 | stagedSummary: "M README.md", |
| 200 | 201 | stagedPatch: "diff --git a/README.md b/README.md", |
| 201 | 202 | modelSelection: { |
| 202 | provider: "claudeAgent", | |
| 203 | model: "claude-haiku-4-5", | |
| 204 | options: { | |
| 205 | thinking: false, | |
| 206 | effort: "high", | |
| 207 | }, | |
| 203 | ...createModelSelection("claudeAgent", "claude-haiku-4-5", [ | |
| 204 | { id: "thinking", value: false }, | |
| 205 | { id: "effort", value: "high" }, | |
| 206 | ]), | |
| 208 | 207 | }, |
| 209 | 208 | }); |
| 210 | 209 | |
| ... | ... | @@ -235,12 +234,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { |
| 235 | 234 | diffSummary: "1 file changed", |
| 236 | 235 | diffPatch: "diff --git a/README.md b/README.md", |
| 237 | 236 | modelSelection: { |
| 238 | provider: "claudeAgent", | |
| 239 | model: "claude-opus-4-6", | |
| 240 | options: { | |
| 241 | effort: "max", | |
| 242 | fastMode: true, | |
| 243 | }, | |
| 237 | ...createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 238 | { id: "effort", value: "max" }, | |
| 239 | { id: "fastMode", value: true }, | |
| 240 | ]), | |
| 244 | 241 | }, |
| 245 | 242 | }); |
| 246 | 243 |
apps/server/src/git/Layers/ClaudeTextGeneration.ts+28-12| ... | ... | @@ -28,10 +28,17 @@ import { |
| 28 | 28 | sanitizeThreadTitle, |
| 29 | 29 | toJsonSchemaObject, |
| 30 | 30 | } from "../Utils.ts"; |
| 31 | import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; | |
| 32 | import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 31 | import { | |
| 32 | getModelSelectionStringOptionValue, | |
| 33 | getProviderOptionDescriptors, | |
| 34 | } from "@t3tools/shared/model"; | |
| 35 | import { | |
| 36 | getClaudeModelCapabilities, | |
| 37 | normalizeClaudeCliEffort, | |
| 38 | resolveClaudeApiModelId, | |
| 39 | resolveClaudeEffort, | |
| 40 | } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 33 | 41 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 34 | import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 35 | 42 | |
| 36 | 43 | const CLAUDE_TIMEOUT_MS = 180_000; |
| 37 | 44 | |
| ... | ... | @@ -84,15 +91,24 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 84 | 91 | modelSelection: ClaudeModelSelection; |
| 85 | 92 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { |
| 86 | 93 | const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); |
| 87 | const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( | |
| 88 | getClaudeModelCapabilities(modelSelection.model), | |
| 89 | modelSelection.options, | |
| 90 | ); | |
| 94 | const caps = getClaudeModelCapabilities(modelSelection.model); | |
| 95 | const descriptors = getProviderOptionDescriptors({ | |
| 96 | caps, | |
| 97 | selections: modelSelection.options, | |
| 98 | }); | |
| 99 | const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); | |
| 100 | const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); | |
| 101 | const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); | |
| 102 | const cliEffort = normalizeClaudeCliEffort(resolvedEffort); | |
| 103 | const thinkingDescriptor = findDescriptor("thinking"); | |
| 104 | const fastModeDescriptor = findDescriptor("fastMode"); | |
| 105 | const thinking = | |
| 106 | thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; | |
| 107 | const fastMode = | |
| 108 | fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; | |
| 91 | 109 | const settings = { |
| 92 | ...(typeof normalizedOptions?.thinking === "boolean" | |
| 93 | ? { alwaysThinkingEnabled: normalizedOptions.thinking } | |
| 94 | : {}), | |
| 95 | ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), | |
| 110 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | |
| 111 | ...(fastMode ? { fastMode: true } : {}), | |
| 96 | 112 | }; |
| 97 | 113 | |
| 98 | 114 | const claudeSettings = yield* Effect.map( |
| ... | ... | @@ -111,7 +127,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { |
| 111 | 127 | jsonSchemaStr, |
| 112 | 128 | "--model", |
| 113 | 129 | resolveClaudeApiModelId(modelSelection), |
| 114 | ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), | |
| 130 | ...(cliEffort ? ["--effort", cliEffort] : []), | |
| 115 | 131 | ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), |
| 116 | 132 | "--dangerously-skip-permissions", |
| 117 | 133 | ], |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+5-8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { it } from "@effect/vitest"; |
| 3 | 3 | import { Effect, FileSystem, Layer, Path, Result } from "effect"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 4 | 5 | import { expect } from "vitest"; |
| 5 | 6 | |
| 6 | 7 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -244,14 +245,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 244 | 245 | branch: "feature/codex-effect", |
| 245 | 246 | stagedSummary: "M README.md", |
| 246 | 247 | stagedPatch: "diff --git a/README.md b/README.md", |
| 247 | modelSelection: { | |
| 248 | provider: "codex", | |
| 249 | model: "gpt-5.4", | |
| 250 | options: { | |
| 251 | reasoningEffort: "xhigh", | |
| 252 | fastMode: true, | |
| 253 | }, | |
| 254 | }, | |
| 248 | modelSelection: createModelSelection("codex", "gpt-5.4", [ | |
| 249 | { id: "reasoningEffort", value: "xhigh" }, | |
| 250 | { id: "fastMode", value: true }, | |
| 251 | ]), | |
| 255 | 252 | }); |
| 256 | 253 | }), |
| 257 | 254 | ), |
apps/server/src/git/Layers/CodexTextGeneration.ts+9-2| ... | ... | @@ -30,6 +30,10 @@ import { |
| 30 | 30 | toJsonSchemaObject, |
| 31 | 31 | } from "../Utils.ts"; |
| 32 | 32 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 33 | import { | |
| 34 | getModelSelectionBooleanOptionValue, | |
| 35 | getModelSelectionStringOptionValue, | |
| 36 | } from "@t3tools/shared/model"; | |
| 33 | 37 | |
| 34 | 38 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; |
| 35 | 39 | const CODEX_TIMEOUT_MS = 180_000; |
| ... | ... | @@ -155,7 +159,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 155 | 159 | |
| 156 | 160 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { |
| 157 | 161 | const reasoningEffort = |
| 158 | modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | |
| 162 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | |
| 163 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | |
| 159 | 164 | const command = ChildProcess.make( |
| 160 | 165 | codexSettings?.binaryPath || "codex", |
| 161 | 166 | [ |
| ... | ... | @@ -168,7 +173,9 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 168 | 173 | modelSelection.model, |
| 169 | 174 | "--config", |
| 170 | 175 | `model_reasoning_effort="${reasoningEffort}"`, |
| 171 | ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []), | |
| 176 | ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true | |
| 177 | ? ["--config", `service_tier="fast"`] | |
| 178 | : []), | |
| 172 | 179 | "--output-schema", |
| 173 | 180 | schemaPath, |
| 174 | 181 | "--output-last-message", |
apps/server/src/git/Layers/CursorTextGeneration.test.ts+6-7| ... | ... | @@ -6,6 +6,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import { it } from "@effect/vitest"; |
| 8 | 8 | import { Effect, Layer } from "effect"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 9 | 10 | import { expect } from "vitest"; |
| 10 | 11 | |
| 11 | 12 | import { ServerSettingsError } from "@t3tools/contracts"; |
| ... | ... | @@ -135,13 +136,11 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { |
| 135 | 136 | stagedPatch: |
| 136 | 137 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", |
| 137 | 138 | modelSelection: { |
| 138 | provider: "cursor", | |
| 139 | model: "gpt-5.4", | |
| 140 | options: { | |
| 141 | reasoning: "xhigh", | |
| 142 | fastMode: true, | |
| 143 | contextWindow: "1m", | |
| 144 | }, | |
| 139 | ...createModelSelection("cursor", "gpt-5.4", [ | |
| 140 | { id: "reasoning", value: "xhigh" }, | |
| 141 | { id: "fastMode", value: true }, | |
| 142 | { id: "contextWindow", value: "1m" }, | |
| 143 | ]), | |
| 145 | 144 | }, |
| 146 | 145 | }); |
| 147 | 146 |
apps/server/src/git/Layers/CursorTextGeneration.ts+1-1| ... | ... | @@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { |
| 108 | 108 | yield* applyCursorAcpModelSelection({ |
| 109 | 109 | runtime, |
| 110 | 110 | model: modelSelection.model, |
| 111 | modelOptions: modelSelection.options, | |
| 111 | selections: modelSelection.options, | |
| 112 | 112 | mapError: ({ cause, configId, step }) => |
| 113 | 113 | mapCursorAcpError( |
| 114 | 114 | operation, |
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+8-6| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type OpenCodeModelSelection, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 10 | 11 | |
| 11 | 12 | import { ServerConfig } from "../../config.ts"; |
| 12 | 13 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | ... | @@ -320,16 +321,17 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 320 | 321 | if (!session.data) { |
| 321 | 322 | throw new Error("OpenCode session.create returned no session payload."); |
| 322 | 323 | } |
| 324 | const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); | |
| 325 | const selectedVariant = getModelSelectionStringOptionValue( | |
| 326 | input.modelSelection, | |
| 327 | "variant", | |
| 328 | ); | |
| 323 | 329 | |
| 324 | 330 | const result = await client.session.prompt({ |
| 325 | 331 | sessionID: session.data.id, |
| 326 | 332 | model: parsedModel, |
| 327 | ...(input.modelSelection.options?.agent | |
| 328 | ? { agent: input.modelSelection.options.agent } | |
| 329 | : {}), | |
| 330 | ...(input.modelSelection.options?.variant | |
| 331 | ? { variant: input.modelSelection.options.variant } | |
| 332 | : {}), | |
| 333 | ...(selectedAgent ? { agent: selectedAgent } : {}), | |
| 334 | ...(selectedVariant ? { variant: selectedVariant } : {}), | |
| 333 | 335 | parts: [{ type: "text", text: input.prompt }, ...fileParts], |
| 334 | 336 | }); |
| 335 | 337 | const info = result.data?.info; |
apps/server/src/git/Layers/RoutingTextGeneration.ts+14-22| ... | ... | @@ -11,11 +11,7 @@ |
| 11 | 11 | */ |
| 12 | 12 | import { Effect, Layer, Context } from "effect"; |
| 13 | 13 | |
| 14 | import { | |
| 15 | TextGeneration, | |
| 16 | type TextGenerationProvider, | |
| 17 | type TextGenerationShape, | |
| 18 | } from "../Services/TextGeneration.ts"; | |
| 14 | import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; | |
| 19 | 15 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 20 | 16 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 21 | 17 | import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; |
| ... | ... | @@ -46,26 +42,22 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha |
| 46 | 42 | // --------------------------------------------------------------------------- |
| 47 | 43 | |
| 48 | 44 | const makeRoutingTextGeneration = Effect.gen(function* () { |
| 49 | const codex = yield* CodexTextGen; | |
| 50 | const claude = yield* ClaudeTextGen; | |
| 51 | const cursor = yield* CursorTextGen; | |
| 52 | const openCode = yield* OpenCodeTextGen; | |
| 53 | ||
| 54 | const route = (provider?: TextGenerationProvider): TextGenerationShape => | |
| 55 | provider === "claudeAgent" | |
| 56 | ? claude | |
| 57 | : provider === "opencode" | |
| 58 | ? openCode | |
| 59 | : provider === "cursor" | |
| 60 | ? cursor | |
| 61 | : codex; | |
| 45 | const byProvider = { | |
| 46 | codex: yield* CodexTextGen, | |
| 47 | claudeAgent: yield* ClaudeTextGen, | |
| 48 | cursor: yield* CursorTextGen, | |
| 49 | opencode: yield* OpenCodeTextGen, | |
| 50 | }; | |
| 62 | 51 | |
| 63 | 52 | return { |
| 64 | 53 | generateCommitMessage: (input) => |
| 65 | route(input.modelSelection.provider).generateCommitMessage(input), | |
| 66 | generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), | |
| 67 | generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), | |
| 68 | generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), | |
| 54 | byProvider[input.modelSelection.provider].generateCommitMessage(input), | |
| 55 | generatePrContent: (input) => | |
| 56 | byProvider[input.modelSelection.provider].generatePrContent(input), | |
| 57 | generateBranchName: (input) => | |
| 58 | byProvider[input.modelSelection.provider].generateBranchName(input), | |
| 59 | generateThreadTitle: (input) => | |
| 60 | byProvider[input.modelSelection.provider].generateThreadTitle(input), | |
| 69 | 61 | } satisfies TextGenerationShape; |
| 70 | 62 | }); |
| 71 | 63 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+40-87| ... | ... | @@ -3,6 +3,7 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 6 | 7 | import { |
| 7 | 8 | ApprovalRequestId, |
| 8 | 9 | CommandId, |
| ... | ... | @@ -567,14 +568,10 @@ describe("ProviderCommandReactor", () => { |
| 567 | 568 | text: "hello fast mode", |
| 568 | 569 | attachments: [], |
| 569 | 570 | }, |
| 570 | modelSelection: { | |
| 571 | provider: "codex", | |
| 572 | model: "gpt-5.3-codex", | |
| 573 | options: { | |
| 574 | reasoningEffort: "high", | |
| 575 | fastMode: true, | |
| 576 | }, | |
| 577 | }, | |
| 571 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 572 | { id: "reasoningEffort", value: "high" }, | |
| 573 | { id: "fastMode", value: true }, | |
| 574 | ]), | |
| 578 | 575 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 579 | 576 | runtimeMode: "approval-required", |
| 580 | 577 | createdAt: now, |
| ... | ... | @@ -584,25 +581,17 @@ describe("ProviderCommandReactor", () => { |
| 584 | 581 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 585 | 582 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 586 | 583 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 587 | modelSelection: { | |
| 588 | provider: "codex", | |
| 589 | model: "gpt-5.3-codex", | |
| 590 | options: { | |
| 591 | reasoningEffort: "high", | |
| 592 | fastMode: true, | |
| 593 | }, | |
| 594 | }, | |
| 584 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 585 | { id: "reasoningEffort", value: "high" }, | |
| 586 | { id: "fastMode", value: true }, | |
| 587 | ]), | |
| 595 | 588 | }); |
| 596 | 589 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 597 | 590 | threadId: ThreadId.make("thread-1"), |
| 598 | modelSelection: { | |
| 599 | provider: "codex", | |
| 600 | model: "gpt-5.3-codex", | |
| 601 | options: { | |
| 602 | reasoningEffort: "high", | |
| 603 | fastMode: true, | |
| 604 | }, | |
| 605 | }, | |
| 591 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 592 | { id: "reasoningEffort", value: "high" }, | |
| 593 | { id: "fastMode", value: true }, | |
| 594 | ]), | |
| 606 | 595 | }); |
| 607 | 596 | }); |
| 608 | 597 | |
| ... | ... | @@ -623,13 +612,9 @@ describe("ProviderCommandReactor", () => { |
| 623 | 612 | text: "hello with effort", |
| 624 | 613 | attachments: [], |
| 625 | 614 | }, |
| 626 | modelSelection: { | |
| 627 | provider: "claudeAgent", | |
| 628 | model: "claude-sonnet-4-6", | |
| 629 | options: { | |
| 630 | effort: "max", | |
| 631 | }, | |
| 632 | }, | |
| 615 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 616 | { id: "effort", value: "max" }, | |
| 617 | ]), | |
| 633 | 618 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 634 | 619 | runtimeMode: "approval-required", |
| 635 | 620 | createdAt: now, |
| ... | ... | @@ -639,23 +624,15 @@ describe("ProviderCommandReactor", () => { |
| 639 | 624 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 640 | 625 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 641 | 626 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 642 | modelSelection: { | |
| 643 | provider: "claudeAgent", | |
| 644 | model: "claude-sonnet-4-6", | |
| 645 | options: { | |
| 646 | effort: "max", | |
| 647 | }, | |
| 648 | }, | |
| 627 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 628 | { id: "effort", value: "max" }, | |
| 629 | ]), | |
| 649 | 630 | }); |
| 650 | 631 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 651 | 632 | threadId: ThreadId.make("thread-1"), |
| 652 | modelSelection: { | |
| 653 | provider: "claudeAgent", | |
| 654 | model: "claude-sonnet-4-6", | |
| 655 | options: { | |
| 656 | effort: "max", | |
| 657 | }, | |
| 658 | }, | |
| 633 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 634 | { id: "effort", value: "max" }, | |
| 635 | ]), | |
| 659 | 636 | }); |
| 660 | 637 | }); |
| 661 | 638 | |
| ... | ... | @@ -676,13 +653,9 @@ describe("ProviderCommandReactor", () => { |
| 676 | 653 | text: "hello with fast mode", |
| 677 | 654 | attachments: [], |
| 678 | 655 | }, |
| 679 | modelSelection: { | |
| 680 | provider: "claudeAgent", | |
| 681 | model: "claude-opus-4-6", | |
| 682 | options: { | |
| 683 | fastMode: true, | |
| 684 | }, | |
| 685 | }, | |
| 656 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 657 | { id: "fastMode", value: true }, | |
| 658 | ]), | |
| 686 | 659 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 687 | 660 | runtimeMode: "approval-required", |
| 688 | 661 | createdAt: now, |
| ... | ... | @@ -692,23 +665,15 @@ describe("ProviderCommandReactor", () => { |
| 692 | 665 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 693 | 666 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 694 | 667 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 695 | modelSelection: { | |
| 696 | provider: "claudeAgent", | |
| 697 | model: "claude-opus-4-6", | |
| 698 | options: { | |
| 699 | fastMode: true, | |
| 700 | }, | |
| 701 | }, | |
| 668 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 669 | { id: "fastMode", value: true }, | |
| 670 | ]), | |
| 702 | 671 | }); |
| 703 | 672 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 704 | 673 | threadId: ThreadId.make("thread-1"), |
| 705 | modelSelection: { | |
| 706 | provider: "claudeAgent", | |
| 707 | model: "claude-opus-4-6", | |
| 708 | options: { | |
| 709 | fastMode: true, | |
| 710 | }, | |
| 711 | }, | |
| 674 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 675 | { id: "fastMode", value: true }, | |
| 676 | ]), | |
| 712 | 677 | }); |
| 713 | 678 | }); |
| 714 | 679 | |
| ... | ... | @@ -916,13 +881,9 @@ describe("ProviderCommandReactor", () => { |
| 916 | 881 | text: "first claude turn", |
| 917 | 882 | attachments: [], |
| 918 | 883 | }, |
| 919 | modelSelection: { | |
| 920 | provider: "claudeAgent", | |
| 921 | model: "claude-sonnet-4-6", | |
| 922 | options: { | |
| 923 | effort: "medium", | |
| 924 | }, | |
| 925 | }, | |
| 884 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 885 | { id: "effort", value: "medium" }, | |
| 886 | ]), | |
| 926 | 887 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 927 | 888 | runtimeMode: "approval-required", |
| 928 | 889 | createdAt: now, |
| ... | ... | @@ -943,13 +904,9 @@ describe("ProviderCommandReactor", () => { |
| 943 | 904 | text: "second claude turn", |
| 944 | 905 | attachments: [], |
| 945 | 906 | }, |
| 946 | modelSelection: { | |
| 947 | provider: "claudeAgent", | |
| 948 | model: "claude-sonnet-4-6", | |
| 949 | options: { | |
| 950 | effort: "max", | |
| 951 | }, | |
| 952 | }, | |
| 907 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 908 | { id: "effort", value: "max" }, | |
| 909 | ]), | |
| 953 | 910 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 954 | 911 | runtimeMode: "approval-required", |
| 955 | 912 | createdAt: now, |
| ... | ... | @@ -960,13 +917,9 @@ describe("ProviderCommandReactor", () => { |
| 960 | 917 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 961 | 918 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 962 | 919 | resumeCursor: { opaque: "resume-1" }, |
| 963 | modelSelection: { | |
| 964 | provider: "claudeAgent", | |
| 965 | model: "claude-sonnet-4-6", | |
| 966 | options: { | |
| 967 | effort: "max", | |
| 968 | }, | |
| 969 | }, | |
| 920 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 921 | { id: "effort", value: "max" }, | |
| 922 | ]), | |
| 970 | 923 | }); |
| 971 | 924 | }); |
| 972 | 925 |
apps/server/src/orchestration/decider.projectScripts.test.ts+9-16| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 9 | 10 | import { describe, expect, it } from "vitest"; |
| 10 | 11 | import { Effect } from "effect"; |
| 11 | 12 | |
| ... | ... | @@ -162,14 +163,10 @@ describe("decider project scripts", () => { |
| 162 | 163 | text: "hello", |
| 163 | 164 | attachments: [], |
| 164 | 165 | }, |
| 165 | modelSelection: { | |
| 166 | provider: "codex", | |
| 167 | model: "gpt-5.3-codex", | |
| 168 | options: { | |
| 169 | reasoningEffort: "high", | |
| 170 | fastMode: true, | |
| 171 | }, | |
| 172 | }, | |
| 166 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 167 | { id: "reasoningEffort", value: "high" }, | |
| 168 | { id: "fastMode", value: true }, | |
| 169 | ]), | |
| 173 | 170 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 174 | 171 | runtimeMode: "approval-required", |
| 175 | 172 | createdAt: now, |
| ... | ... | @@ -191,14 +188,10 @@ describe("decider project scripts", () => { |
| 191 | 188 | expect(turnStartEvent.payload).toMatchObject({ |
| 192 | 189 | threadId: ThreadId.make("thread-1"), |
| 193 | 190 | messageId: asMessageId("message-user-1"), |
| 194 | modelSelection: { | |
| 195 | provider: "codex", | |
| 196 | model: "gpt-5.3-codex", | |
| 197 | options: { | |
| 198 | reasoningEffort: "high", | |
| 199 | fastMode: true, | |
| 200 | }, | |
| 201 | }, | |
| 191 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 192 | { id: "reasoningEffort", value: "high" }, | |
| 193 | { id: "fastMode", value: true }, | |
| 194 | ]), | |
| 202 | 195 | runtimeMode: "approval-required", |
| 203 | 196 | }); |
| 204 | 197 | }); |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -38,6 +38,7 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; |
| 38 | 38 | import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; |
| 39 | 39 | import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; |
| 40 | 40 | import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; |
| 41 | import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; | |
| 41 | 42 | |
| 42 | 43 | /** |
| 43 | 44 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -75,6 +76,7 @@ export const migrationEntries = [ |
| 75 | 76 | [23, "ProjectionThreadShellSummary", Migration0023], |
| 76 | 77 | [24, "BackfillProjectionThreadShellSummary", Migration0024], |
| 77 | 78 | [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], |
| 79 | [26, "CanonicalizeModelSelectionOptions", Migration0026], | |
| 78 | 80 | ] as const; |
| 79 | 81 | |
| 80 | 82 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts created+450| ... | ... | @@ -0,0 +1,450 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Layer } from "effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import { runMigrations } from "../Migrations.ts"; | |
| 6 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | |
| 7 | ||
| 8 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 9 | ||
| 10 | layer("026_CanonicalizeModelSelectionOptions", (it) => { | |
| 11 | it.effect("converts legacy object-shape options into array-shape on projections and events", () => | |
| 12 | Effect.gen(function* () { | |
| 13 | const sql = yield* SqlClient.SqlClient; | |
| 14 | ||
| 15 | yield* runMigrations({ toMigrationInclusive: 25 }); | |
| 16 | ||
| 17 | yield* sql` | |
| 18 | INSERT INTO projection_projects ( | |
| 19 | project_id, | |
| 20 | title, | |
| 21 | workspace_root, | |
| 22 | default_model_selection_json, | |
| 23 | scripts_json, | |
| 24 | created_at, | |
| 25 | updated_at, | |
| 26 | deleted_at | |
| 27 | ) | |
| 28 | VALUES | |
| 29 | ( | |
| 30 | 'project-legacy', | |
| 31 | 'Legacy options project', | |
| 32 | '/tmp/legacy', | |
| 33 | '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}', | |
| 34 | '[]', | |
| 35 | '2026-01-01T00:00:00.000Z', | |
| 36 | '2026-01-01T00:00:00.000Z', | |
| 37 | NULL | |
| 38 | ), | |
| 39 | ( | |
| 40 | 'project-no-options', | |
| 41 | 'No options project', | |
| 42 | '/tmp/no-options', | |
| 43 | '{"provider":"codex","model":"gpt-5.4"}', | |
| 44 | '[]', | |
| 45 | '2026-01-01T00:00:00.000Z', | |
| 46 | '2026-01-01T00:00:00.000Z', | |
| 47 | NULL | |
| 48 | ), | |
| 49 | ( | |
| 50 | 'project-null-selection', | |
| 51 | 'Null model selection project', | |
| 52 | '/tmp/null-selection', | |
| 53 | NULL, | |
| 54 | '[]', | |
| 55 | '2026-01-01T00:00:00.000Z', | |
| 56 | '2026-01-01T00:00:00.000Z', | |
| 57 | NULL | |
| 58 | ), | |
| 59 | ( | |
| 60 | 'project-already-array', | |
| 61 | 'Already-canonical options project', | |
| 62 | '/tmp/already-array', | |
| 63 | '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}', | |
| 64 | '[]', | |
| 65 | '2026-01-01T00:00:00.000Z', | |
| 66 | '2026-01-01T00:00:00.000Z', | |
| 67 | NULL | |
| 68 | ) | |
| 69 | `; | |
| 70 | ||
| 71 | yield* sql` | |
| 72 | INSERT INTO projection_threads ( | |
| 73 | thread_id, | |
| 74 | project_id, | |
| 75 | title, | |
| 76 | model_selection_json, | |
| 77 | branch, | |
| 78 | worktree_path, | |
| 79 | latest_turn_id, | |
| 80 | created_at, | |
| 81 | updated_at, | |
| 82 | archived_at, | |
| 83 | latest_user_message_at, | |
| 84 | pending_approval_count, | |
| 85 | pending_user_input_count, | |
| 86 | has_actionable_proposed_plan, | |
| 87 | deleted_at, | |
| 88 | runtime_mode, | |
| 89 | interaction_mode | |
| 90 | ) | |
| 91 | VALUES | |
| 92 | ( | |
| 93 | 'thread-legacy', | |
| 94 | 'project-legacy', | |
| 95 | 'Legacy thread', | |
| 96 | '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}', | |
| 97 | NULL, NULL, NULL, | |
| 98 | '2026-01-01T00:00:00.000Z', | |
| 99 | '2026-01-01T00:00:00.000Z', | |
| 100 | NULL, NULL, 0, 0, 0, NULL, | |
| 101 | 'full-access', 'default' | |
| 102 | ), | |
| 103 | ( | |
| 104 | 'thread-empty-options', | |
| 105 | 'project-legacy', | |
| 106 | 'Empty options thread', | |
| 107 | '{"provider":"codex","model":"gpt-5.4","options":{}}', | |
| 108 | NULL, NULL, NULL, | |
| 109 | '2026-01-01T00:00:00.000Z', | |
| 110 | '2026-01-01T00:00:00.000Z', | |
| 111 | NULL, NULL, 0, 0, 0, NULL, | |
| 112 | 'full-access', 'default' | |
| 113 | ), | |
| 114 | ( | |
| 115 | 'thread-drop-garbage', | |
| 116 | 'project-legacy', | |
| 117 | 'Thread with non-scalar entries', | |
| 118 | '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}', | |
| 119 | NULL, NULL, NULL, | |
| 120 | '2026-01-01T00:00:00.000Z', | |
| 121 | '2026-01-01T00:00:00.000Z', | |
| 122 | NULL, NULL, 0, 0, 0, NULL, | |
| 123 | 'full-access', 'default' | |
| 124 | ), | |
| 125 | ( | |
| 126 | 'thread-no-options', | |
| 127 | 'project-legacy', | |
| 128 | 'No options thread', | |
| 129 | '{"provider":"codex","model":"gpt-5.4"}', | |
| 130 | NULL, NULL, NULL, | |
| 131 | '2026-01-01T00:00:00.000Z', | |
| 132 | '2026-01-01T00:00:00.000Z', | |
| 133 | NULL, NULL, 0, 0, 0, NULL, | |
| 134 | 'full-access', 'default' | |
| 135 | ), | |
| 136 | ( | |
| 137 | 'thread-already-array', | |
| 138 | 'project-legacy', | |
| 139 | 'Already array thread', | |
| 140 | '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}', | |
| 141 | NULL, NULL, NULL, | |
| 142 | '2026-01-01T00:00:00.000Z', | |
| 143 | '2026-01-01T00:00:00.000Z', | |
| 144 | NULL, NULL, 0, 0, 0, NULL, | |
| 145 | 'full-access', 'default' | |
| 146 | ) | |
| 147 | `; | |
| 148 | ||
| 149 | yield* sql` | |
| 150 | INSERT INTO orchestration_events ( | |
| 151 | event_id, | |
| 152 | aggregate_kind, | |
| 153 | stream_id, | |
| 154 | stream_version, | |
| 155 | event_type, | |
| 156 | occurred_at, | |
| 157 | command_id, | |
| 158 | causation_event_id, | |
| 159 | correlation_id, | |
| 160 | actor_kind, | |
| 161 | payload_json, | |
| 162 | metadata_json | |
| 163 | ) | |
| 164 | VALUES | |
| 165 | ( | |
| 166 | 'event-project-created', | |
| 167 | 'project', | |
| 168 | 'project-legacy', | |
| 169 | 1, | |
| 170 | 'project.created', | |
| 171 | '2026-01-01T00:00:00.000Z', | |
| 172 | 'cmd-pc', | |
| 173 | NULL, | |
| 174 | 'corr-pc', | |
| 175 | 'user', | |
| 176 | '{"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"}', | |
| 177 | '{}' | |
| 178 | ), | |
| 179 | ( | |
| 180 | 'event-project-meta-updated', | |
| 181 | 'project', | |
| 182 | 'project-legacy', | |
| 183 | 2, | |
| 184 | 'project.meta-updated', | |
| 185 | '2026-01-01T00:00:00.000Z', | |
| 186 | 'cmd-pmu', | |
| 187 | NULL, | |
| 188 | 'corr-pmu', | |
| 189 | 'user', | |
| 190 | '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}', | |
| 191 | '{}' | |
| 192 | ), | |
| 193 | ( | |
| 194 | 'event-project-null-selection', | |
| 195 | 'project', | |
| 196 | 'project-legacy', | |
| 197 | 3, | |
| 198 | 'project.meta-updated', | |
| 199 | '2026-01-01T00:00:00.000Z', | |
| 200 | 'cmd-null', | |
| 201 | NULL, | |
| 202 | 'corr-null', | |
| 203 | 'user', | |
| 204 | '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}', | |
| 205 | '{}' | |
| 206 | ), | |
| 207 | ( | |
| 208 | 'event-thread-created', | |
| 209 | 'thread', | |
| 210 | 'thread-legacy', | |
| 211 | 1, | |
| 212 | 'thread.created', | |
| 213 | '2026-01-01T00:00:00.000Z', | |
| 214 | 'cmd-tc', | |
| 215 | NULL, | |
| 216 | 'corr-tc', | |
| 217 | 'user', | |
| 218 | '{"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"}', | |
| 219 | '{}' | |
| 220 | ), | |
| 221 | ( | |
| 222 | 'event-thread-meta-updated', | |
| 223 | 'thread', | |
| 224 | 'thread-legacy', | |
| 225 | 2, | |
| 226 | 'thread.meta-updated', | |
| 227 | '2026-01-01T00:00:00.000Z', | |
| 228 | 'cmd-tmu', | |
| 229 | NULL, | |
| 230 | 'corr-tmu', | |
| 231 | 'user', | |
| 232 | '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}', | |
| 233 | '{}' | |
| 234 | ), | |
| 235 | ( | |
| 236 | 'event-thread-turn-start', | |
| 237 | 'thread', | |
| 238 | 'thread-legacy', | |
| 239 | 3, | |
| 240 | 'thread.turn-start-requested', | |
| 241 | '2026-01-01T00:00:00.000Z', | |
| 242 | 'cmd-tts', | |
| 243 | NULL, | |
| 244 | 'corr-tts', | |
| 245 | 'user', | |
| 246 | '{"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"}', | |
| 247 | '{}' | |
| 248 | ), | |
| 249 | ( | |
| 250 | 'event-thread-already-array', | |
| 251 | 'thread', | |
| 252 | 'thread-legacy', | |
| 253 | 4, | |
| 254 | 'thread.created', | |
| 255 | '2026-01-01T00:00:00.000Z', | |
| 256 | 'cmd-taa', | |
| 257 | NULL, | |
| 258 | 'corr-taa', | |
| 259 | 'user', | |
| 260 | '{"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"}', | |
| 261 | '{}' | |
| 262 | ), | |
| 263 | ( | |
| 264 | 'event-activity-append', | |
| 265 | 'thread', | |
| 266 | 'thread-legacy', | |
| 267 | 5, | |
| 268 | 'thread.activity-appended', | |
| 269 | '2026-01-01T00:00:00.000Z', | |
| 270 | 'cmd-aa', | |
| 271 | NULL, | |
| 272 | 'corr-aa', | |
| 273 | 'user', | |
| 274 | '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}', | |
| 275 | '{}' | |
| 276 | ) | |
| 277 | `; | |
| 278 | ||
| 279 | yield* runMigrations({ toMigrationInclusive: 26 }); | |
| 280 | ||
| 281 | // Projection projects | |
| 282 | const projectRows = yield* sql<{ | |
| 283 | readonly projectId: string; | |
| 284 | readonly defaultModelSelection: string | null; | |
| 285 | }>` | |
| 286 | SELECT | |
| 287 | project_id AS "projectId", | |
| 288 | default_model_selection_json AS "defaultModelSelection" | |
| 289 | FROM projection_projects | |
| 290 | ORDER BY project_id | |
| 291 | `; | |
| 292 | assert.deepStrictEqual( | |
| 293 | projectRows.map((row) => ({ | |
| 294 | projectId: row.projectId, | |
| 295 | selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, | |
| 296 | })), | |
| 297 | [ | |
| 298 | { | |
| 299 | projectId: "project-already-array", | |
| 300 | selection: { | |
| 301 | provider: "codex", | |
| 302 | model: "gpt-5.4", | |
| 303 | options: [{ id: "reasoningEffort", value: "high" }], | |
| 304 | }, | |
| 305 | }, | |
| 306 | { | |
| 307 | projectId: "project-legacy", | |
| 308 | selection: { | |
| 309 | provider: "claudeAgent", | |
| 310 | model: "claude-opus-4-6", | |
| 311 | options: [ | |
| 312 | { id: "effort", value: "max" }, | |
| 313 | { id: "fastMode", value: true }, | |
| 314 | ], | |
| 315 | }, | |
| 316 | }, | |
| 317 | { | |
| 318 | projectId: "project-no-options", | |
| 319 | selection: { provider: "codex", model: "gpt-5.4" }, | |
| 320 | }, | |
| 321 | { projectId: "project-null-selection", selection: null }, | |
| 322 | ], | |
| 323 | ); | |
| 324 | ||
| 325 | // Projection threads | |
| 326 | const threadRows = yield* sql<{ | |
| 327 | readonly threadId: string; | |
| 328 | readonly modelSelection: string | null; | |
| 329 | }>` | |
| 330 | SELECT | |
| 331 | thread_id AS "threadId", | |
| 332 | model_selection_json AS "modelSelection" | |
| 333 | FROM projection_threads | |
| 334 | ORDER BY thread_id | |
| 335 | `; | |
| 336 | assert.deepStrictEqual( | |
| 337 | threadRows.map((row) => ({ | |
| 338 | threadId: row.threadId, | |
| 339 | selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, | |
| 340 | })), | |
| 341 | [ | |
| 342 | { | |
| 343 | threadId: "thread-already-array", | |
| 344 | selection: { | |
| 345 | provider: "codex", | |
| 346 | model: "gpt-5.4", | |
| 347 | options: [{ id: "fastMode", value: true }], | |
| 348 | }, | |
| 349 | }, | |
| 350 | { | |
| 351 | threadId: "thread-drop-garbage", | |
| 352 | selection: { | |
| 353 | provider: "claudeAgent", | |
| 354 | model: "claude-opus-4-6", | |
| 355 | // Only the scalar string survives; nested object, whitespace | |
| 356 | // string, and null are dropped. | |
| 357 | options: [{ id: "effort", value: "high" }], | |
| 358 | }, | |
| 359 | }, | |
| 360 | { | |
| 361 | threadId: "thread-empty-options", | |
| 362 | selection: { provider: "codex", model: "gpt-5.4", options: [] }, | |
| 363 | }, | |
| 364 | { | |
| 365 | threadId: "thread-legacy", | |
| 366 | selection: { | |
| 367 | provider: "claudeAgent", | |
| 368 | model: "claude-opus-4-6", | |
| 369 | options: [ | |
| 370 | { id: "effort", value: "max" }, | |
| 371 | { id: "thinking", value: false }, | |
| 372 | { id: "contextWindow", value: "1m" }, | |
| 373 | ], | |
| 374 | }, | |
| 375 | }, | |
| 376 | { | |
| 377 | threadId: "thread-no-options", | |
| 378 | selection: { provider: "codex", model: "gpt-5.4" }, | |
| 379 | }, | |
| 380 | ], | |
| 381 | ); | |
| 382 | ||
| 383 | // Orchestration events | |
| 384 | const eventRows = yield* sql<{ | |
| 385 | readonly eventId: string; | |
| 386 | readonly payloadJson: string; | |
| 387 | }>` | |
| 388 | SELECT event_id AS "eventId", payload_json AS "payloadJson" | |
| 389 | FROM orchestration_events | |
| 390 | ORDER BY event_id | |
| 391 | `; | |
| 392 | ||
| 393 | const payloads = Object.fromEntries( | |
| 394 | eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), | |
| 395 | ); | |
| 396 | ||
| 397 | assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { | |
| 398 | provider: "claudeAgent", | |
| 399 | model: "claude-opus-4-6", | |
| 400 | options: [ | |
| 401 | { id: "effort", value: "max" }, | |
| 402 | { id: "fastMode", value: true }, | |
| 403 | ], | |
| 404 | }); | |
| 405 | ||
| 406 | assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { | |
| 407 | provider: "codex", | |
| 408 | model: "gpt-5.4", | |
| 409 | options: [{ id: "reasoningEffort", value: "low" }], | |
| 410 | }); | |
| 411 | ||
| 412 | assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); | |
| 413 | ||
| 414 | assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { | |
| 415 | provider: "claudeAgent", | |
| 416 | model: "claude-opus-4-6", | |
| 417 | options: [ | |
| 418 | { id: "effort", value: "max" }, | |
| 419 | { id: "thinking", value: false }, | |
| 420 | ], | |
| 421 | }); | |
| 422 | ||
| 423 | assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { | |
| 424 | provider: "codex", | |
| 425 | model: "gpt-5.4", | |
| 426 | options: [{ id: "fastMode", value: true }], | |
| 427 | }); | |
| 428 | ||
| 429 | assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { | |
| 430 | provider: "claudeAgent", | |
| 431 | model: "claude-opus-4-6", | |
| 432 | options: [ | |
| 433 | { id: "effort", value: "high" }, | |
| 434 | { id: "contextWindow", value: "1m" }, | |
| 435 | ], | |
| 436 | }); | |
| 437 | ||
| 438 | // Already-array records are left untouched. | |
| 439 | assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { | |
| 440 | provider: "codex", | |
| 441 | model: "gpt-5.4", | |
| 442 | options: [{ id: "reasoningEffort", value: "medium" }], | |
| 443 | }); | |
| 444 | ||
| 445 | // Events with no modelSelection at all are untouched. | |
| 446 | assert.isUndefined(payloads["event-activity-append"].modelSelection); | |
| 447 | assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); | |
| 448 | }), | |
| 449 | ); | |
| 450 | }); |
apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts created+138| ... | ... | @@ -0,0 +1,138 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | /** | |
| 5 | * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from | |
| 6 | * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the | |
| 7 | * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`). | |
| 8 | * | |
| 9 | * Migration 016 introduced `modelSelection` with `options` stored as a | |
| 10 | * per-provider object. Later the schema was reshaped so that options are a | |
| 11 | * generic `Array<{ id, value }>` of user-selected option entries. Stored rows | |
| 12 | * from before the reshape still have the object shape and fail to decode. | |
| 13 | * | |
| 14 | * For each value in the legacy object: | |
| 15 | * - string values are kept if non-empty after trim | |
| 16 | * - boolean values are always kept (true | false) | |
| 17 | * - any other value type (number, null, nested object/array) is dropped, | |
| 18 | * matching the permissive client-side normalizer in composerDraftStore. | |
| 19 | * | |
| 20 | * Touched storage: | |
| 21 | * - `projection_threads.model_selection_json.options` | |
| 22 | * - `projection_projects.default_model_selection_json.options` | |
| 23 | * - `orchestration_events.payload_json.$.modelSelection.options` | |
| 24 | * (thread.created | thread.meta-updated | thread.turn-start-requested) | |
| 25 | * - `orchestration_events.payload_json.$.defaultModelSelection.options` | |
| 26 | * (project.created | project.meta-updated) | |
| 27 | */ | |
| 28 | export default Effect.gen(function* () { | |
| 29 | const sql = yield* SqlClient.SqlClient; | |
| 30 | ||
| 31 | yield* sql` | |
| 32 | UPDATE projection_threads | |
| 33 | SET model_selection_json = json_set( | |
| 34 | model_selection_json, | |
| 35 | '$.options', | |
| 36 | ( | |
| 37 | SELECT json_group_array( | |
| 38 | json_object( | |
| 39 | 'id', key, | |
| 40 | 'value', | |
| 41 | CASE type | |
| 42 | WHEN 'true' THEN json('true') | |
| 43 | WHEN 'false' THEN json('false') | |
| 44 | ELSE atom | |
| 45 | END | |
| 46 | ) | |
| 47 | ) | |
| 48 | FROM json_each(json_extract(model_selection_json, '$.options')) | |
| 49 | WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') | |
| 50 | OR type IN ('true', 'false') | |
| 51 | ) | |
| 52 | ) | |
| 53 | WHERE model_selection_json IS NOT NULL | |
| 54 | AND json_type(model_selection_json, '$.options') = 'object' | |
| 55 | `; | |
| 56 | ||
| 57 | yield* sql` | |
| 58 | UPDATE projection_projects | |
| 59 | SET default_model_selection_json = json_set( | |
| 60 | default_model_selection_json, | |
| 61 | '$.options', | |
| 62 | ( | |
| 63 | SELECT json_group_array( | |
| 64 | json_object( | |
| 65 | 'id', key, | |
| 66 | 'value', | |
| 67 | CASE type | |
| 68 | WHEN 'true' THEN json('true') | |
| 69 | WHEN 'false' THEN json('false') | |
| 70 | ELSE atom | |
| 71 | END | |
| 72 | ) | |
| 73 | ) | |
| 74 | FROM json_each(json_extract(default_model_selection_json, '$.options')) | |
| 75 | WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') | |
| 76 | OR type IN ('true', 'false') | |
| 77 | ) | |
| 78 | ) | |
| 79 | WHERE default_model_selection_json IS NOT NULL | |
| 80 | AND json_type(default_model_selection_json, '$.options') = 'object' | |
| 81 | `; | |
| 82 | ||
| 83 | yield* sql` | |
| 84 | UPDATE orchestration_events | |
| 85 | SET payload_json = json_set( | |
| 86 | payload_json, | |
| 87 | '$.modelSelection.options', | |
| 88 | ( | |
| 89 | SELECT json_group_array( | |
| 90 | json_object( | |
| 91 | 'id', key, | |
| 92 | 'value', | |
| 93 | CASE type | |
| 94 | WHEN 'true' THEN json('true') | |
| 95 | WHEN 'false' THEN json('false') | |
| 96 | ELSE atom | |
| 97 | END | |
| 98 | ) | |
| 99 | ) | |
| 100 | FROM json_each(json_extract(payload_json, '$.modelSelection.options')) | |
| 101 | WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') | |
| 102 | OR type IN ('true', 'false') | |
| 103 | ) | |
| 104 | ) | |
| 105 | WHERE event_type IN ( | |
| 106 | 'thread.created', | |
| 107 | 'thread.meta-updated', | |
| 108 | 'thread.turn-start-requested' | |
| 109 | ) | |
| 110 | AND json_type(payload_json, '$.modelSelection.options') = 'object' | |
| 111 | `; | |
| 112 | ||
| 113 | yield* sql` | |
| 114 | UPDATE orchestration_events | |
| 115 | SET payload_json = json_set( | |
| 116 | payload_json, | |
| 117 | '$.defaultModelSelection.options', | |
| 118 | ( | |
| 119 | SELECT json_group_array( | |
| 120 | json_object( | |
| 121 | 'id', key, | |
| 122 | 'value', | |
| 123 | CASE type | |
| 124 | WHEN 'true' THEN json('true') | |
| 125 | WHEN 'false' THEN json('false') | |
| 126 | ELSE atom | |
| 127 | END | |
| 128 | ) | |
| 129 | ) | |
| 130 | FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options')) | |
| 131 | WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') | |
| 132 | OR type IN ('true', 'false') | |
| 133 | ) | |
| 134 | ) | |
| 135 | WHERE event_type IN ('project.created', 'project.meta-updated') | |
| 136 | AND json_type(payload_json, '$.defaultModelSelection.options') = 'object' | |
| 137 | `; | |
| 138 | }); |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+34-77| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | type RuntimeMode, |
| 18 | 18 | ThreadId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 20 | 21 | import { assert, describe, it } from "@effect/vitest"; |
| 21 | 22 | import { Effect, Fiber, Layer, Random, Stream } from "effect"; |
| 22 | 23 | |
| ... | ... | @@ -333,13 +334,9 @@ describe("ClaudeAdapterLive", () => { |
| 333 | 334 | yield* adapter.startSession({ |
| 334 | 335 | threadId: THREAD_ID, |
| 335 | 336 | provider: "claudeAgent", |
| 336 | modelSelection: { | |
| 337 | provider: "claudeAgent", | |
| 338 | model: "claude-opus-4-6", | |
| 339 | options: { | |
| 340 | effort: "max", | |
| 341 | }, | |
| 342 | }, | |
| 337 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 338 | { id: "effort", value: "max" }, | |
| 339 | ]), | |
| 343 | 340 | runtimeMode: "full-access", |
| 344 | 341 | }); |
| 345 | 342 | |
| ... | ... | @@ -380,13 +377,9 @@ describe("ClaudeAdapterLive", () => { |
| 380 | 377 | yield* adapter.startSession({ |
| 381 | 378 | threadId: THREAD_ID, |
| 382 | 379 | provider: "claudeAgent", |
| 383 | modelSelection: { | |
| 384 | provider: "claudeAgent", | |
| 385 | model: "claude-opus-4-7", | |
| 386 | options: { | |
| 387 | effort: "xhigh", | |
| 388 | }, | |
| 389 | }, | |
| 380 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [ | |
| 381 | { id: "effort", value: "xhigh" }, | |
| 382 | ]), | |
| 390 | 383 | runtimeMode: "full-access", |
| 391 | 384 | }); |
| 392 | 385 | |
| ... | ... | @@ -405,13 +398,9 @@ describe("ClaudeAdapterLive", () => { |
| 405 | 398 | yield* adapter.startSession({ |
| 406 | 399 | threadId: THREAD_ID, |
| 407 | 400 | provider: "claudeAgent", |
| 408 | modelSelection: { | |
| 409 | provider: "claudeAgent", | |
| 410 | model: "claude-sonnet-4-6", | |
| 411 | options: { | |
| 412 | effort: "max", | |
| 413 | }, | |
| 414 | }, | |
| 401 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 402 | { id: "effort", value: "max" }, | |
| 403 | ]), | |
| 415 | 404 | runtimeMode: "full-access", |
| 416 | 405 | }); |
| 417 | 406 | |
| ... | ... | @@ -430,13 +419,9 @@ describe("ClaudeAdapterLive", () => { |
| 430 | 419 | yield* adapter.startSession({ |
| 431 | 420 | threadId: THREAD_ID, |
| 432 | 421 | provider: "claudeAgent", |
| 433 | modelSelection: { | |
| 434 | provider: "claudeAgent", | |
| 435 | model: "claude-haiku-4-5", | |
| 436 | options: { | |
| 437 | effort: "high", | |
| 438 | }, | |
| 439 | }, | |
| 422 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | |
| 423 | { id: "effort", value: "high" }, | |
| 424 | ]), | |
| 440 | 425 | runtimeMode: "full-access", |
| 441 | 426 | }); |
| 442 | 427 | |
| ... | ... | @@ -455,13 +440,9 @@ describe("ClaudeAdapterLive", () => { |
| 455 | 440 | yield* adapter.startSession({ |
| 456 | 441 | threadId: THREAD_ID, |
| 457 | 442 | provider: "claudeAgent", |
| 458 | modelSelection: { | |
| 459 | provider: "claudeAgent", | |
| 460 | model: "claude-haiku-4-5", | |
| 461 | options: { | |
| 462 | thinking: false, | |
| 463 | }, | |
| 464 | }, | |
| 443 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | |
| 444 | { id: "thinking", value: false }, | |
| 445 | ]), | |
| 465 | 446 | runtimeMode: "full-access", |
| 466 | 447 | }); |
| 467 | 448 | |
| ... | ... | @@ -482,13 +463,9 @@ describe("ClaudeAdapterLive", () => { |
| 482 | 463 | yield* adapter.startSession({ |
| 483 | 464 | threadId: THREAD_ID, |
| 484 | 465 | provider: "claudeAgent", |
| 485 | modelSelection: { | |
| 486 | provider: "claudeAgent", | |
| 487 | model: "claude-sonnet-4-6", | |
| 488 | options: { | |
| 489 | thinking: false, | |
| 490 | }, | |
| 491 | }, | |
| 466 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 467 | { id: "thinking", value: false }, | |
| 468 | ]), | |
| 492 | 469 | runtimeMode: "full-access", |
| 493 | 470 | }); |
| 494 | 471 | |
| ... | ... | @@ -507,13 +484,9 @@ describe("ClaudeAdapterLive", () => { |
| 507 | 484 | yield* adapter.startSession({ |
| 508 | 485 | threadId: THREAD_ID, |
| 509 | 486 | provider: "claudeAgent", |
| 510 | modelSelection: { | |
| 511 | provider: "claudeAgent", | |
| 512 | model: "claude-opus-4-6", | |
| 513 | options: { | |
| 514 | fastMode: true, | |
| 515 | }, | |
| 516 | }, | |
| 487 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 488 | { id: "fastMode", value: true }, | |
| 489 | ]), | |
| 517 | 490 | runtimeMode: "full-access", |
| 518 | 491 | }); |
| 519 | 492 | |
| ... | ... | @@ -534,13 +507,9 @@ describe("ClaudeAdapterLive", () => { |
| 534 | 507 | yield* adapter.startSession({ |
| 535 | 508 | threadId: THREAD_ID, |
| 536 | 509 | provider: "claudeAgent", |
| 537 | modelSelection: { | |
| 538 | provider: "claudeAgent", | |
| 539 | model: "claude-sonnet-4-6", | |
| 540 | options: { | |
| 541 | fastMode: true, | |
| 542 | }, | |
| 543 | }, | |
| 510 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 511 | { id: "fastMode", value: true }, | |
| 512 | ]), | |
| 544 | 513 | runtimeMode: "full-access", |
| 545 | 514 | }); |
| 546 | 515 | |
| ... | ... | @@ -559,13 +528,9 @@ describe("ClaudeAdapterLive", () => { |
| 559 | 528 | const session = yield* adapter.startSession({ |
| 560 | 529 | threadId: THREAD_ID, |
| 561 | 530 | provider: "claudeAgent", |
| 562 | modelSelection: { | |
| 563 | provider: "claudeAgent", | |
| 564 | model: "claude-sonnet-4-6", | |
| 565 | options: { | |
| 566 | effort: "ultrathink", | |
| 567 | }, | |
| 568 | }, | |
| 531 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 532 | { id: "effort", value: "ultrathink" }, | |
| 533 | ]), | |
| 569 | 534 | runtimeMode: "full-access", |
| 570 | 535 | }); |
| 571 | 536 | |
| ... | ... | @@ -573,13 +538,9 @@ describe("ClaudeAdapterLive", () => { |
| 573 | 538 | threadId: session.threadId, |
| 574 | 539 | input: "Investigate the edge cases", |
| 575 | 540 | attachments: [], |
| 576 | modelSelection: { | |
| 577 | provider: "claudeAgent", | |
| 578 | model: "claude-sonnet-4-6", | |
| 579 | options: { | |
| 580 | effort: "ultrathink", | |
| 581 | }, | |
| 582 | }, | |
| 541 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 542 | { id: "effort", value: "ultrathink" }, | |
| 543 | ]), | |
| 583 | 544 | }); |
| 584 | 545 | |
| 585 | 546 | const createInput = harness.getLastCreateQueryInput(); |
| ... | ... | @@ -2794,13 +2755,9 @@ describe("ClaudeAdapterLive", () => { |
| 2794 | 2755 | yield* adapter.sendTurn({ |
| 2795 | 2756 | threadId: session.threadId, |
| 2796 | 2757 | input: "hello", |
| 2797 | modelSelection: { | |
| 2798 | provider: "claudeAgent", | |
| 2799 | model: "claude-opus-4-6", | |
| 2800 | options: { | |
| 2801 | contextWindow: "1m", | |
| 2802 | }, | |
| 2803 | }, | |
| 2758 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 2759 | { id: "contextWindow", value: "1m" }, | |
| 2760 | ]), | |
| 2804 | 2761 | attachments: [], |
| 2805 | 2762 | }); |
| 2806 | 2763 | yield* adapter.sendTurn({ |
apps/server/src/provider/Layers/ClaudeAdapter.ts+35-29| ... | ... | @@ -40,9 +40,14 @@ import { |
| 40 | 40 | ThreadId, |
| 41 | 41 | TurnId, |
| 42 | 42 | type UserInputQuestion, |
| 43 | ClaudeAgentEffort, | |
| 44 | 43 | } from "@t3tools/contracts"; |
| 45 | import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; | |
| 44 | import { | |
| 45 | applyClaudePromptEffortPrefix, | |
| 46 | getModelSelectionBooleanOptionValue, | |
| 47 | getModelSelectionStringOptionValue, | |
| 48 | getProviderOptionDescriptors, | |
| 49 | resolvePromptInjectedEffort, | |
| 50 | } from "@t3tools/shared/model"; | |
| 46 | 51 | import { |
| 47 | 52 | Cause, |
| 48 | 53 | DateTime, |
| ... | ... | @@ -61,7 +66,12 @@ import { |
| 61 | 66 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 62 | 67 | import { ServerConfig } from "../../config.ts"; |
| 63 | 68 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 64 | import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; | |
| 69 | import { | |
| 70 | getClaudeModelCapabilities, | |
| 71 | normalizeClaudeCliEffort, | |
| 72 | resolveClaudeApiModelId, | |
| 73 | resolveClaudeEffort, | |
| 74 | } from "./ClaudeProvider.ts"; | |
| 65 | 75 | import { |
| 66 | 76 | ProviderAdapterProcessError, |
| 67 | 77 | ProviderAdapterRequestError, |
| ... | ... | @@ -211,19 +221,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray |
| 211 | 221 | return squashed.length > 0 ? [squashed] : []; |
| 212 | 222 | } |
| 213 | 223 | |
| 214 | function getEffectiveClaudeAgentEffort( | |
| 215 | effort: ClaudeAgentEffort | null | undefined, | |
| 216 | ): ClaudeSdkEffort | null { | |
| 217 | if (!effort) { | |
| 218 | return null; | |
| 219 | } | |
| 220 | if (effort === "ultrathink") { | |
| 221 | return null; | |
| 222 | } | |
| 223 | if (effort === "xhigh") { | |
| 224 | return "max"; | |
| 225 | } | |
| 226 | return effort; | |
| 224 | function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { | |
| 225 | const normalized = normalizeClaudeCliEffort(effort); | |
| 226 | return normalized ? (normalized as ClaudeSdkEffort) : null; | |
| 227 | 227 | } |
| 228 | 228 | |
| 229 | 229 | function isClaudeInterruptedMessage(message: string): boolean { |
| ... | ... | @@ -555,16 +555,14 @@ const CLAUDE_SETTING_SOURCES = [ |
| 555 | 555 | |
| 556 | 556 | function buildPromptText(input: ProviderSendTurnInput): string { |
| 557 | 557 | const rawEffort = |
| 558 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; | |
| 558 | input.modelSelection?.provider === "claudeAgent" | |
| 559 | ? getModelSelectionStringOptionValue(input.modelSelection, "effort") | |
| 560 | : null; | |
| 559 | 561 | const claudeModel = |
| 560 | 562 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; |
| 561 | 563 | const caps = getClaudeModelCapabilities(claudeModel); |
| 562 | 564 | |
| 563 | // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). | |
| 564 | // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. | |
| 565 | const trimmedEffort = trimOrNull(rawEffort); | |
| 566 | const promptEffort = | |
| 567 | trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; | |
| 565 | const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); | |
| 568 | 566 | return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); |
| 569 | 567 | } |
| 570 | 568 | |
| ... | ... | @@ -2824,14 +2822,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2824 | 2822 | const modelSelection = |
| 2825 | 2823 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; |
| 2826 | 2824 | const caps = getClaudeModelCapabilities(modelSelection?.model); |
| 2825 | const descriptors = getProviderOptionDescriptors({ caps }); | |
| 2827 | 2826 | const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; |
| 2828 | const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? | |
| 2829 | null) as ClaudeAgentEffort | null; | |
| 2830 | const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; | |
| 2831 | const thinking = | |
| 2832 | typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle | |
| 2833 | ? modelSelection.options.thinking | |
| 2834 | : undefined; | |
| 2827 | const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); | |
| 2828 | const effort = resolveClaudeEffort(caps, rawEffort) ?? null; | |
| 2829 | const fastModeSupported = descriptors.some( | |
| 2830 | (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", | |
| 2831 | ); | |
| 2832 | const thinkingSupported = descriptors.some( | |
| 2833 | (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", | |
| 2834 | ); | |
| 2835 | const fastMode = | |
| 2836 | getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && | |
| 2837 | fastModeSupported; | |
| 2838 | const thinking = thinkingSupported | |
| 2839 | ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") | |
| 2840 | : undefined; | |
| 2835 | 2841 | const effectiveEffort = getEffectiveClaudeAgentEffort(effort); |
| 2836 | 2842 | const runtimeModeToPermission: Record<string, PermissionMode> = { |
| 2837 | 2843 | "auto-accept-edits": "acceptEdits", |
apps/server/src/provider/Layers/ClaudeProvider.ts+156-71| ... | ... | @@ -11,6 +11,12 @@ import type { |
| 11 | 11 | import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; |
| 12 | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 14 | import { | |
| 15 | createModelCapabilities, | |
| 16 | getModelSelectionStringOptionValue, | |
| 17 | getProviderOptionCurrentValue, | |
| 18 | getProviderOptionDescriptors, | |
| 19 | } from "@t3tools/shared/model"; | |
| 14 | 20 | import { |
| 15 | 21 | query as claudeQuery, |
| 16 | 22 | type SlashCommand as ClaudeSlashCommand, |
| ... | ... | @@ -18,6 +24,8 @@ import { |
| 18 | 24 | } from "@anthropic-ai/claude-agent-sdk"; |
| 19 | 25 | |
| 20 | 26 | import { |
| 27 | buildBooleanOptionDescriptor, | |
| 28 | buildSelectOptionDescriptor, | |
| 21 | 29 | buildServerProvider, |
| 22 | 30 | AUTH_PROBE_TIMEOUT_MS, |
| 23 | 31 | DEFAULT_TIMEOUT_MS, |
| ... | ... | @@ -35,108 +43,143 @@ import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; |
| 35 | 43 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 36 | 44 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 37 | 45 | |
| 38 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { | |
| 39 | reasoningEffortLevels: [], | |
| 40 | supportsFastMode: false, | |
| 41 | supportsThinkingToggle: false, | |
| 42 | contextWindowOptions: [], | |
| 43 | promptInjectedEffortLevels: [], | |
| 44 | }; | |
| 46 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 47 | optionDescriptors: [], | |
| 48 | }); | |
| 45 | 49 | |
| 46 | 50 | const PROVIDER = "claudeAgent" as const; |
| 51 | const CLAUDE_PRESENTATION = { | |
| 52 | displayName: "Claude", | |
| 53 | showInteractionModeToggle: true, | |
| 54 | } as const; | |
| 47 | 55 | const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; |
| 48 | 56 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 49 | 57 | { |
| 50 | 58 | slug: "claude-opus-4-7", |
| 51 | 59 | name: "Claude Opus 4.7", |
| 52 | 60 | isCustom: false, |
| 53 | capabilities: { | |
| 54 | reasoningEffortLevels: [ | |
| 55 | { value: "low", label: "Low" }, | |
| 56 | { value: "medium", label: "Medium" }, | |
| 57 | { value: "high", label: "High" }, | |
| 58 | { value: "xhigh", label: "Extra High", isDefault: true }, | |
| 59 | { value: "max", label: "Max" }, | |
| 60 | { value: "ultrathink", label: "Ultrathink" }, | |
| 61 | capabilities: createModelCapabilities({ | |
| 62 | optionDescriptors: [ | |
| 63 | buildSelectOptionDescriptor({ | |
| 64 | id: "effort", | |
| 65 | label: "Reasoning", | |
| 66 | options: [ | |
| 67 | { value: "low", label: "Low" }, | |
| 68 | { value: "medium", label: "Medium" }, | |
| 69 | { value: "high", label: "High" }, | |
| 70 | { value: "xhigh", label: "Extra High", isDefault: true }, | |
| 71 | { value: "max", label: "Max" }, | |
| 72 | { value: "ultrathink", label: "Ultrathink" }, | |
| 73 | ], | |
| 74 | promptInjectedValues: ["ultrathink"], | |
| 75 | }), | |
| 76 | buildSelectOptionDescriptor({ | |
| 77 | id: "contextWindow", | |
| 78 | label: "Context Window", | |
| 79 | options: [ | |
| 80 | { value: "200k", label: "200k", isDefault: true }, | |
| 81 | { value: "1m", label: "1M" }, | |
| 82 | ], | |
| 83 | }), | |
| 61 | 84 | ], |
| 62 | supportsFastMode: false, | |
| 63 | supportsThinkingToggle: false, | |
| 64 | contextWindowOptions: [ | |
| 65 | { value: "200k", label: "200k", isDefault: true }, | |
| 66 | { value: "1m", label: "1M" }, | |
| 67 | ], | |
| 68 | promptInjectedEffortLevels: ["ultrathink"], | |
| 69 | } satisfies ModelCapabilities, | |
| 85 | }), | |
| 70 | 86 | }, |
| 71 | 87 | { |
| 72 | 88 | slug: "claude-opus-4-6", |
| 73 | 89 | name: "Claude Opus 4.6", |
| 74 | 90 | isCustom: false, |
| 75 | capabilities: { | |
| 76 | reasoningEffortLevels: [ | |
| 77 | { value: "low", label: "Low" }, | |
| 78 | { value: "medium", label: "Medium" }, | |
| 79 | { value: "high", label: "High", isDefault: true }, | |
| 80 | { value: "max", label: "Max" }, | |
| 81 | { value: "ultrathink", label: "Ultrathink" }, | |
| 82 | ], | |
| 83 | supportsFastMode: true, | |
| 84 | supportsThinkingToggle: false, | |
| 85 | contextWindowOptions: [ | |
| 86 | { value: "200k", label: "200k", isDefault: true }, | |
| 87 | { value: "1m", label: "1M" }, | |
| 91 | capabilities: createModelCapabilities({ | |
| 92 | optionDescriptors: [ | |
| 93 | buildSelectOptionDescriptor({ | |
| 94 | id: "effort", | |
| 95 | label: "Reasoning", | |
| 96 | options: [ | |
| 97 | { value: "low", label: "Low" }, | |
| 98 | { value: "medium", label: "Medium" }, | |
| 99 | { value: "high", label: "High", isDefault: true }, | |
| 100 | { value: "max", label: "Max" }, | |
| 101 | { value: "ultrathink", label: "Ultrathink" }, | |
| 102 | ], | |
| 103 | promptInjectedValues: ["ultrathink"], | |
| 104 | }), | |
| 105 | buildBooleanOptionDescriptor({ | |
| 106 | id: "fastMode", | |
| 107 | label: "Fast Mode", | |
| 108 | }), | |
| 109 | buildSelectOptionDescriptor({ | |
| 110 | id: "contextWindow", | |
| 111 | label: "Context Window", | |
| 112 | options: [ | |
| 113 | { value: "200k", label: "200k", isDefault: true }, | |
| 114 | { value: "1m", label: "1M" }, | |
| 115 | ], | |
| 116 | }), | |
| 88 | 117 | ], |
| 89 | promptInjectedEffortLevels: ["ultrathink"], | |
| 90 | } satisfies ModelCapabilities, | |
| 118 | }), | |
| 91 | 119 | }, |
| 92 | 120 | { |
| 93 | 121 | slug: "claude-opus-4-5", |
| 94 | 122 | name: "Claude Opus 4.5", |
| 95 | 123 | isCustom: false, |
| 96 | capabilities: { | |
| 97 | reasoningEffortLevels: [ | |
| 98 | { value: "low", label: "Low" }, | |
| 99 | { value: "medium", label: "Medium" }, | |
| 100 | { value: "high", label: "High", isDefault: true }, | |
| 101 | { value: "max", label: "Max" }, | |
| 124 | capabilities: createModelCapabilities({ | |
| 125 | optionDescriptors: [ | |
| 126 | buildSelectOptionDescriptor({ | |
| 127 | id: "effort", | |
| 128 | label: "Reasoning", | |
| 129 | options: [ | |
| 130 | { value: "low", label: "Low" }, | |
| 131 | { value: "medium", label: "Medium" }, | |
| 132 | { value: "high", label: "High", isDefault: true }, | |
| 133 | { value: "max", label: "Max" }, | |
| 134 | ], | |
| 135 | }), | |
| 136 | buildBooleanOptionDescriptor({ | |
| 137 | id: "fastMode", | |
| 138 | label: "Fast Mode", | |
| 139 | }), | |
| 102 | 140 | ], |
| 103 | supportsFastMode: true, | |
| 104 | supportsThinkingToggle: false, | |
| 105 | contextWindowOptions: [], | |
| 106 | promptInjectedEffortLevels: [], | |
| 107 | } satisfies ModelCapabilities, | |
| 141 | }), | |
| 108 | 142 | }, |
| 109 | 143 | { |
| 110 | 144 | slug: "claude-sonnet-4-6", |
| 111 | 145 | name: "Claude Sonnet 4.6", |
| 112 | 146 | isCustom: false, |
| 113 | capabilities: { | |
| 114 | reasoningEffortLevels: [ | |
| 115 | { value: "low", label: "Low" }, | |
| 116 | { value: "medium", label: "Medium" }, | |
| 117 | { value: "high", label: "High", isDefault: true }, | |
| 118 | { value: "ultrathink", label: "Ultrathink" }, | |
| 119 | ], | |
| 120 | supportsFastMode: false, | |
| 121 | supportsThinkingToggle: false, | |
| 122 | contextWindowOptions: [ | |
| 123 | { value: "200k", label: "200k", isDefault: true }, | |
| 124 | { value: "1m", label: "1M" }, | |
| 147 | capabilities: createModelCapabilities({ | |
| 148 | optionDescriptors: [ | |
| 149 | buildSelectOptionDescriptor({ | |
| 150 | id: "effort", | |
| 151 | label: "Reasoning", | |
| 152 | options: [ | |
| 153 | { value: "low", label: "Low" }, | |
| 154 | { value: "medium", label: "Medium" }, | |
| 155 | { value: "high", label: "High", isDefault: true }, | |
| 156 | { value: "ultrathink", label: "Ultrathink" }, | |
| 157 | ], | |
| 158 | promptInjectedValues: ["ultrathink"], | |
| 159 | }), | |
| 160 | buildSelectOptionDescriptor({ | |
| 161 | id: "contextWindow", | |
| 162 | label: "Context Window", | |
| 163 | options: [ | |
| 164 | { value: "200k", label: "200k", isDefault: true }, | |
| 165 | { value: "1m", label: "1M" }, | |
| 166 | ], | |
| 167 | }), | |
| 125 | 168 | ], |
| 126 | promptInjectedEffortLevels: ["ultrathink"], | |
| 127 | } satisfies ModelCapabilities, | |
| 169 | }), | |
| 128 | 170 | }, |
| 129 | 171 | { |
| 130 | 172 | slug: "claude-haiku-4-5", |
| 131 | 173 | name: "Claude Haiku 4.5", |
| 132 | 174 | isCustom: false, |
| 133 | capabilities: { | |
| 134 | reasoningEffortLevels: [], | |
| 135 | supportsFastMode: false, | |
| 136 | supportsThinkingToggle: true, | |
| 137 | contextWindowOptions: [], | |
| 138 | promptInjectedEffortLevels: [], | |
| 139 | } satisfies ModelCapabilities, | |
| 175 | capabilities: createModelCapabilities({ | |
| 176 | optionDescriptors: [ | |
| 177 | buildBooleanOptionDescriptor({ | |
| 178 | id: "thinking", | |
| 179 | label: "Thinking", | |
| 180 | }), | |
| 181 | ], | |
| 182 | }), | |
| 140 | 183 | }, |
| 141 | 184 | ]; |
| 142 | 185 | |
| ... | ... | @@ -166,8 +209,41 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo |
| 166 | 209 | ); |
| 167 | 210 | } |
| 168 | 211 | |
| 212 | export function resolveClaudeEffort( | |
| 213 | caps: ModelCapabilities, | |
| 214 | raw: string | null | undefined, | |
| 215 | ): string | undefined { | |
| 216 | const descriptors = getProviderOptionDescriptors({ | |
| 217 | caps, | |
| 218 | ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), | |
| 219 | }); | |
| 220 | const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); | |
| 221 | const value = getProviderOptionCurrentValue(effortDescriptor); | |
| 222 | return typeof value === "string" ? value : undefined; | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Normalize a resolved Claude effort value into one suitable for the Claude | |
| 227 | * CLI's `--effort` flag. | |
| 228 | * | |
| 229 | * Mirrors the mapping used when invoking the Claude Agent SDK | |
| 230 | * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): the Opus 4.7 | |
| 231 | * capability `"xhigh"` is rewritten to the accepted CLI value `"max"`, and | |
| 232 | * `"ultrathink"` is filtered out because it is a prompt-prefix mode rather | |
| 233 | * than a CLI-effort value. Returns `undefined` when no flag should be passed. | |
| 234 | */ | |
| 235 | export function normalizeClaudeCliEffort(effort: string | null | undefined): string | undefined { | |
| 236 | if (!effort || effort === "ultrathink") { | |
| 237 | return undefined; | |
| 238 | } | |
| 239 | if (effort === "xhigh") { | |
| 240 | return "max"; | |
| 241 | } | |
| 242 | return effort; | |
| 243 | } | |
| 244 | ||
| 169 | 245 | export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { |
| 170 | switch (modelSelection.options?.contextWindow) { | |
| 246 | switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { | |
| 171 | 247 | case "1m": |
| 172 | 248 | return `${modelSelection.model}[1m]`; |
| 173 | 249 | default: |
| ... | ... | @@ -579,6 +655,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 579 | 655 | if (!claudeSettings.enabled) { |
| 580 | 656 | return buildServerProvider({ |
| 581 | 657 | provider: PROVIDER, |
| 658 | presentation: CLAUDE_PRESENTATION, | |
| 582 | 659 | enabled: false, |
| 583 | 660 | checkedAt, |
| 584 | 661 | models: allModels, |
| ... | ... | @@ -601,6 +678,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 601 | 678 | const error = versionProbe.failure; |
| 602 | 679 | return buildServerProvider({ |
| 603 | 680 | provider: PROVIDER, |
| 681 | presentation: CLAUDE_PRESENTATION, | |
| 604 | 682 | enabled: claudeSettings.enabled, |
| 605 | 683 | checkedAt, |
| 606 | 684 | models: allModels, |
| ... | ... | @@ -619,6 +697,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 619 | 697 | if (Option.isNone(versionProbe.success)) { |
| 620 | 698 | return buildServerProvider({ |
| 621 | 699 | provider: PROVIDER, |
| 700 | presentation: CLAUDE_PRESENTATION, | |
| 622 | 701 | enabled: claudeSettings.enabled, |
| 623 | 702 | checkedAt, |
| 624 | 703 | models: allModels, |
| ... | ... | @@ -639,6 +718,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 639 | 718 | const detail = detailFromResult(version); |
| 640 | 719 | return buildServerProvider({ |
| 641 | 720 | provider: PROVIDER, |
| 721 | presentation: CLAUDE_PRESENTATION, | |
| 642 | 722 | enabled: claudeSettings.enabled, |
| 643 | 723 | checkedAt, |
| 644 | 724 | models: allModels, |
| ... | ... | @@ -703,6 +783,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 703 | 783 | const error = authProbe.failure; |
| 704 | 784 | return buildServerProvider({ |
| 705 | 785 | provider: PROVIDER, |
| 786 | presentation: CLAUDE_PRESENTATION, | |
| 706 | 787 | enabled: claudeSettings.enabled, |
| 707 | 788 | checkedAt, |
| 708 | 789 | models, |
| ... | ... | @@ -723,6 +804,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 723 | 804 | if (Option.isNone(authProbe.success)) { |
| 724 | 805 | return buildServerProvider({ |
| 725 | 806 | provider: PROVIDER, |
| 807 | presentation: CLAUDE_PRESENTATION, | |
| 726 | 808 | enabled: claudeSettings.enabled, |
| 727 | 809 | checkedAt, |
| 728 | 810 | models, |
| ... | ... | @@ -741,6 +823,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 741 | 823 | const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); |
| 742 | 824 | return buildServerProvider({ |
| 743 | 825 | provider: PROVIDER, |
| 826 | presentation: CLAUDE_PRESENTATION, | |
| 744 | 827 | enabled: claudeSettings.enabled, |
| 745 | 828 | checkedAt, |
| 746 | 829 | models, |
| ... | ... | @@ -774,6 +857,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid |
| 774 | 857 | if (!claudeSettings.enabled) { |
| 775 | 858 | return buildServerProvider({ |
| 776 | 859 | provider: PROVIDER, |
| 860 | presentation: CLAUDE_PRESENTATION, | |
| 777 | 861 | enabled: false, |
| 778 | 862 | checkedAt, |
| 779 | 863 | models, |
| ... | ... | @@ -789,6 +873,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid |
| 789 | 873 | |
| 790 | 874 | return buildServerProvider({ |
| 791 | 875 | provider: PROVIDER, |
| 876 | presentation: CLAUDE_PRESENTATION, | |
| 792 | 877 | enabled: true, |
| 793 | 878 | checkedAt, |
| 794 | 879 | models, |
apps/server/src/provider/Layers/CodexAdapter.test.ts+8-15| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | ThreadId, |
| 15 | 15 | TurnId, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 17 | 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 18 | 19 | import { it, vi } from "@effect/vitest"; |
| 19 | 20 | |
| ... | ... | @@ -242,13 +243,9 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 242 | 243 | yield* adapter.startSession({ |
| 243 | 244 | provider: "codex", |
| 244 | 245 | threadId: asThreadId("thread-1"), |
| 245 | modelSelection: { | |
| 246 | provider: "codex", | |
| 247 | model: "gpt-5.3-codex", | |
| 248 | options: { | |
| 249 | fastMode: true, | |
| 250 | }, | |
| 251 | }, | |
| 246 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 247 | { id: "fastMode", value: true }, | |
| 248 | ]), | |
| 252 | 249 | runtimeMode: "full-access", |
| 253 | 250 | }); |
| 254 | 251 | |
| ... | ... | @@ -309,14 +306,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 309 | 306 | adapter.sendTurn({ |
| 310 | 307 | threadId: asThreadId("sess-missing"), |
| 311 | 308 | input: "hello", |
| 312 | modelSelection: { | |
| 313 | provider: "codex", | |
| 314 | model: "gpt-5.3-codex", | |
| 315 | options: { | |
| 316 | reasoningEffort: "high", | |
| 317 | fastMode: true, | |
| 318 | }, | |
| 319 | }, | |
| 309 | modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 310 | { id: "reasoningEffort", value: "high" }, | |
| 311 | { id: "fastMode", value: true }, | |
| 312 | ]), | |
| 320 | 313 | attachments: [], |
| 321 | 314 | }), |
| 322 | 315 | ); |
apps/server/src/provider/Layers/CodexAdapter.ts+20-7| ... | ... | @@ -26,6 +26,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 26 | 26 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 27 | 27 | import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 28 | 28 | |
| 29 | import { | |
| 30 | getModelSelectionBooleanOptionValue, | |
| 31 | getModelSelectionStringOptionValue, | |
| 32 | } from "@t3tools/shared/model"; | |
| 33 | ||
| 29 | 34 | import { |
| 30 | 35 | ProviderAdapterRequestError, |
| 31 | 36 | ProviderAdapterProcessError, |
| ... | ... | @@ -1372,7 +1377,8 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1372 | 1377 | ...(input.modelSelection?.provider === "codex" |
| 1373 | 1378 | ? { model: input.modelSelection.model } |
| 1374 | 1379 | : {}), |
| 1375 | ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode | |
| 1380 | ...(input.modelSelection?.provider === "codex" && | |
| 1381 | getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true | |
| 1376 | 1382 | ? { serviceTier: "fast" } |
| 1377 | 1383 | : {}), |
| 1378 | 1384 | }; |
| ... | ... | @@ -1485,19 +1491,26 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1485 | 1491 | ); |
| 1486 | 1492 | |
| 1487 | 1493 | const session = yield* requireSession(input.threadId); |
| 1494 | const reasoningEffort = | |
| 1495 | input.modelSelection?.provider === "codex" | |
| 1496 | ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") | |
| 1497 | : undefined; | |
| 1498 | const fastMode = | |
| 1499 | input.modelSelection?.provider === "codex" | |
| 1500 | ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") | |
| 1501 | : undefined; | |
| 1488 | 1502 | return yield* session.runtime |
| 1489 | 1503 | .sendTurn({ |
| 1490 | 1504 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1491 | 1505 | ...(input.modelSelection?.provider === "codex" |
| 1492 | 1506 | ? { model: input.modelSelection.model } |
| 1493 | 1507 | : {}), |
| 1494 | ...(input.modelSelection?.provider === "codex" && | |
| 1495 | input.modelSelection.options?.reasoningEffort !== undefined | |
| 1496 | ? { effort: input.modelSelection.options.reasoningEffort } | |
| 1497 | : {}), | |
| 1498 | ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode | |
| 1499 | ? { serviceTier: "fast" } | |
| 1508 | ...(reasoningEffort | |
| 1509 | ? { | |
| 1510 | effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, | |
| 1511 | } | |
| 1500 | 1512 | : {}), |
| 1513 | ...(fastMode === true ? { serviceTier: "fast" } : {}), | |
| 1501 | 1514 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), |
| 1502 | 1515 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), |
| 1503 | 1516 | }) |
apps/server/src/provider/Layers/CodexProvider.ts+50-11| ... | ... | @@ -25,6 +25,8 @@ import type { |
| 25 | 25 | } from "@t3tools/contracts"; |
| 26 | 26 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 27 | 27 | |
| 28 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 29 | ||
| 28 | 30 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 29 | 31 | import { buildServerProvider } from "../providerSnapshot.ts"; |
| 30 | 32 | import { CodexProvider } from "../Services/CodexProvider.ts"; |
| ... | ... | @@ -34,6 +36,10 @@ import packageJson from "../../../package.json" with { type: "json" }; |
| 34 | 36 | |
| 35 | 37 | const PROVIDER = "codex" as const; |
| 36 | 38 | const PROVIDER_PROBE_TIMEOUT_MS = 8_000; |
| 39 | const CODEX_PRESENTATION = { | |
| 40 | displayName: "Codex", | |
| 41 | showInteractionModeToggle: true, | |
| 42 | } as const; | |
| 37 | 43 | |
| 38 | 44 | export interface CodexAppServerProviderSnapshot { |
| 39 | 45 | readonly account: CodexSchema.V2GetAccountResponse; |
| ... | ... | @@ -87,17 +93,44 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun |
| 87 | 93 | function mapCodexModelCapabilities( |
| 88 | 94 | model: CodexSchema.V2ModelListResponse__Model, |
| 89 | 95 | ): ModelCapabilities { |
| 90 | return { | |
| 91 | reasoningEffortLevels: model.supportedReasoningEfforts.map(({ reasoningEffort }) => ({ | |
| 92 | value: reasoningEffort, | |
| 93 | label: REASONING_EFFORT_LABELS[reasoningEffort], | |
| 94 | ...(reasoningEffort === model.defaultReasoningEffort ? { isDefault: true } : {}), | |
| 95 | })), | |
| 96 | supportsFastMode: (model.additionalSpeedTiers ?? []).includes("fast"), | |
| 97 | supportsThinkingToggle: false, | |
| 98 | contextWindowOptions: [], | |
| 99 | promptInjectedEffortLevels: [], | |
| 100 | }; | |
| 96 | const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => | |
| 97 | reasoningEffort === model.defaultReasoningEffort | |
| 98 | ? { | |
| 99 | id: reasoningEffort, | |
| 100 | label: REASONING_EFFORT_LABELS[reasoningEffort], | |
| 101 | isDefault: true, | |
| 102 | } | |
| 103 | : { | |
| 104 | id: reasoningEffort, | |
| 105 | label: REASONING_EFFORT_LABELS[reasoningEffort], | |
| 106 | }, | |
| 107 | ); | |
| 108 | const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; | |
| 109 | const supportsFastMode = (model.additionalSpeedTiers ?? []).includes("fast"); | |
| 110 | return createModelCapabilities({ | |
| 111 | optionDescriptors: [ | |
| 112 | ...(reasoningOptions.length > 0 | |
| 113 | ? [ | |
| 114 | { | |
| 115 | id: "reasoningEffort", | |
| 116 | label: "Reasoning", | |
| 117 | type: "select" as const, | |
| 118 | options: reasoningOptions, | |
| 119 | ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), | |
| 120 | }, | |
| 121 | ] | |
| 122 | : []), | |
| 123 | ...(supportsFastMode | |
| 124 | ? [ | |
| 125 | { | |
| 126 | id: "fastMode", | |
| 127 | label: "Fast Mode", | |
| 128 | type: "boolean" as const, | |
| 129 | }, | |
| 130 | ] | |
| 131 | : []), | |
| 132 | ], | |
| 133 | }); | |
| 101 | 134 | } |
| 102 | 135 | |
| 103 | 136 | const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { |
| ... | ... | @@ -292,6 +325,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider |
| 292 | 325 | if (!codexSettings.enabled) { |
| 293 | 326 | return buildServerProvider({ |
| 294 | 327 | provider: PROVIDER, |
| 328 | presentation: CODEX_PRESENTATION, | |
| 295 | 329 | enabled: false, |
| 296 | 330 | checkedAt, |
| 297 | 331 | models, |
| ... | ... | @@ -308,6 +342,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider |
| 308 | 342 | |
| 309 | 343 | return buildServerProvider({ |
| 310 | 344 | provider: PROVIDER, |
| 345 | presentation: CODEX_PRESENTATION, | |
| 311 | 346 | enabled: true, |
| 312 | 347 | checkedAt, |
| 313 | 348 | models, |
| ... | ... | @@ -375,6 +410,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 375 | 410 | if (!codexSettings.enabled) { |
| 376 | 411 | return buildServerProvider({ |
| 377 | 412 | provider: PROVIDER, |
| 413 | presentation: CODEX_PRESENTATION, | |
| 378 | 414 | enabled: false, |
| 379 | 415 | checkedAt, |
| 380 | 416 | models: emptyModels, |
| ... | ... | @@ -401,6 +437,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 401 | 437 | const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); |
| 402 | 438 | return buildServerProvider({ |
| 403 | 439 | provider: PROVIDER, |
| 440 | presentation: CODEX_PRESENTATION, | |
| 404 | 441 | enabled: codexSettings.enabled, |
| 405 | 442 | checkedAt, |
| 406 | 443 | models: emptyModels, |
| ... | ... | @@ -420,6 +457,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 420 | 457 | if (Option.isNone(probeResult.success)) { |
| 421 | 458 | return buildServerProvider({ |
| 422 | 459 | provider: PROVIDER, |
| 460 | presentation: CODEX_PRESENTATION, | |
| 423 | 461 | enabled: codexSettings.enabled, |
| 424 | 462 | checkedAt, |
| 425 | 463 | models: emptyModels, |
| ... | ... | @@ -439,6 +477,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 439 | 477 | |
| 440 | 478 | return buildServerProvider({ |
| 441 | 479 | provider: PROVIDER, |
| 480 | presentation: CODEX_PRESENTATION, | |
| 442 | 481 | enabled: codexSettings.enabled, |
| 443 | 482 | checkedAt, |
| 444 | 483 | models: snapshot.models, |
apps/server/src/provider/Layers/CursorAdapter.test.ts+15-12| ... | ... | @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import { assert, it } from "@effect/vitest"; |
| 8 | 8 | import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 9 | 10 | |
| 10 | 11 | import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; |
| 11 | 12 | |
| ... | ... | @@ -357,15 +358,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 357 | 358 | providers: { cursor: { binaryPath: wrapperPath } }, |
| 358 | 359 | }); |
| 359 | 360 | |
| 360 | const modelSelection = { | |
| 361 | provider: "cursor" as const, | |
| 362 | model: "gpt-5.4", | |
| 363 | options: { | |
| 364 | reasoning: "xhigh" as const, | |
| 365 | contextWindow: "1m", | |
| 366 | fastMode: true, | |
| 367 | }, | |
| 368 | }; | |
| 361 | const modelSelection = createModelSelection("cursor", "gpt-5.4", [ | |
| 362 | { id: "reasoning", value: "xhigh" }, | |
| 363 | { id: "contextWindow", value: "1m" }, | |
| 364 | { id: "fastMode", value: true }, | |
| 365 | ]); | |
| 369 | 366 | |
| 370 | 367 | yield* adapter.startSession({ |
| 371 | 368 | threadId, |
| ... | ... | @@ -1092,7 +1089,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1092 | 1089 | threadId, |
| 1093 | 1090 | input: "second turn after switching model", |
| 1094 | 1091 | attachments: [], |
| 1095 | modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, | |
| 1092 | modelSelection: createModelSelection("cursor", "composer-2", [ | |
| 1093 | { id: "fastMode", value: true }, | |
| 1094 | ]), | |
| 1096 | 1095 | }); |
| 1097 | 1096 | |
| 1098 | 1097 | const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); |
| ... | ... | @@ -1147,14 +1146,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1147 | 1146 | threadId, |
| 1148 | 1147 | input: "first turn with fast mode", |
| 1149 | 1148 | attachments: [], |
| 1150 | modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, | |
| 1149 | modelSelection: createModelSelection("cursor", "composer-2", [ | |
| 1150 | { id: "fastMode", value: true }, | |
| 1151 | ]), | |
| 1151 | 1152 | }); |
| 1152 | 1153 | |
| 1153 | 1154 | yield* adapter.sendTurn({ |
| 1154 | 1155 | threadId, |
| 1155 | 1156 | input: "second turn without fast mode", |
| 1156 | 1157 | attachments: [], |
| 1157 | modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, | |
| 1158 | modelSelection: createModelSelection("cursor", "composer-2", [ | |
| 1159 | { id: "fastMode", value: false }, | |
| 1160 | ]), | |
| 1158 | 1161 | }); |
| 1159 | 1162 | |
| 1160 | 1163 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); |
apps/server/src/provider/Layers/CursorAdapter.ts+3-3| ... | ... | @@ -7,7 +7,7 @@ import * as nodePath from "node:path"; |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | ApprovalRequestId, |
| 10 | type CursorModelOptions, | |
| 10 | type ProviderOptionSelection, | |
| 11 | 11 | EventId, |
| 12 | 12 | type ProviderApprovalDecision, |
| 13 | 13 | type ProviderInteractionMode, |
| ... | ... | @@ -222,7 +222,7 @@ function applyRequestedSessionConfiguration<E>(input: { |
| 222 | 222 | readonly modelSelection: |
| 223 | 223 | | { |
| 224 | 224 | readonly model: string; |
| 225 | readonly options?: CursorModelOptions | null | undefined; | |
| 225 | readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined; | |
| 226 | 226 | } |
| 227 | 227 | | undefined; |
| 228 | 228 | readonly mapError: (context: { |
| ... | ... | @@ -235,7 +235,7 @@ function applyRequestedSessionConfiguration<E>(input: { |
| 235 | 235 | yield* applyCursorAcpModelSelection({ |
| 236 | 236 | runtime: input.runtime, |
| 237 | 237 | model: input.modelSelection.model, |
| 238 | modelOptions: input.modelSelection.options, | |
| 238 | selections: input.modelSelection.options, | |
| 239 | 239 | mapError: ({ cause }) => |
| 240 | 240 | input.mapError({ |
| 241 | 241 | cause, |
apps/server/src/provider/Layers/CursorProvider.test.ts+93-102| ... | ... | @@ -8,6 +8,7 @@ import { Effect } from "effect"; |
| 8 | 8 | import { describe, expect, it } from "vitest"; |
| 9 | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | 10 | import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; |
| 11 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 11 | 12 | |
| 12 | 13 | import { |
| 13 | 14 | buildCursorProviderSnapshot, |
| ... | ... | @@ -27,6 +28,31 @@ import { |
| 27 | 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 28 | 29 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| 29 | 30 | |
| 31 | function selectDescriptor( | |
| 32 | id: string, | |
| 33 | label: string, | |
| 34 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 35 | ) { | |
| 36 | return { | |
| 37 | id, | |
| 38 | label, | |
| 39 | type: "select" as const, | |
| 40 | options: [...options], | |
| 41 | ...(options.find((option) => option.isDefault)?.id | |
| 42 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 43 | : {}), | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | |
| 48 | return { | |
| 49 | id, | |
| 50 | label, | |
| 51 | type: "boolean" as const, | |
| 52 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 30 | 56 | async function makeMockAgentWrapper(extraEnv?: Record<string, string>) { |
| 31 | 57 | const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); |
| 32 | 58 | const wrapperPath = path.join(dir, "fake-agent.sh"); |
| ... | ... | @@ -239,13 +265,7 @@ const baseCursorSettings: CursorSettings = { |
| 239 | 265 | customModels: [], |
| 240 | 266 | }; |
| 241 | 267 | |
| 242 | const emptyCapabilities = { | |
| 243 | reasoningEffortLevels: [], | |
| 244 | supportsFastMode: false, | |
| 245 | supportsThinkingToggle: false, | |
| 246 | contextWindowOptions: [], | |
| 247 | promptInjectedEffortLevels: [], | |
| 248 | } as const; | |
| 268 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | |
| 249 | 269 | |
| 250 | 270 | describe("getCursorFallbackModels", () => { |
| 251 | 271 | it("does not publish any built-in cursor models before ACP discovery", () => { |
| ... | ... | @@ -309,52 +329,57 @@ describe("buildCursorProviderSnapshot", () => { |
| 309 | 329 | |
| 310 | 330 | describe("buildCursorCapabilitiesFromConfigOptions", () => { |
| 311 | 331 | it("derives model capabilities from parameterized Cursor ACP config options", () => { |
| 312 | expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ | |
| 313 | reasoningEffortLevels: [ | |
| 314 | { value: "low", label: "Low" }, | |
| 315 | { value: "medium", label: "Medium", isDefault: true }, | |
| 316 | { value: "high", label: "High" }, | |
| 317 | { value: "xhigh", label: "Extra High" }, | |
| 318 | ], | |
| 319 | supportsFastMode: true, | |
| 320 | supportsThinkingToggle: false, | |
| 321 | contextWindowOptions: [ | |
| 322 | { value: "272k", label: "272K", isDefault: true }, | |
| 323 | { value: "1m", label: "1M" }, | |
| 324 | ], | |
| 325 | promptInjectedEffortLevels: [], | |
| 326 | }); | |
| 332 | expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual( | |
| 333 | createModelCapabilities({ | |
| 334 | optionDescriptors: [ | |
| 335 | selectDescriptor("reasoning", "Reasoning", [ | |
| 336 | { id: "low", label: "Low" }, | |
| 337 | { id: "medium", label: "Medium", isDefault: true }, | |
| 338 | { id: "high", label: "High" }, | |
| 339 | { id: "xhigh", label: "Extra High" }, | |
| 340 | ]), | |
| 341 | selectDescriptor("contextWindow", "Context", [ | |
| 342 | { id: "272k", label: "272K", isDefault: true }, | |
| 343 | { id: "1m", label: "1M" }, | |
| 344 | ]), | |
| 345 | booleanDescriptor("fastMode", "Fast", false), | |
| 346 | ], | |
| 347 | }), | |
| 348 | ); | |
| 327 | 349 | }); |
| 328 | 350 | |
| 329 | 351 | it("detects boolean thinking toggles from model_config options", () => { |
| 330 | expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ | |
| 331 | reasoningEffortLevels: [ | |
| 332 | { value: "low", label: "Low" }, | |
| 333 | { value: "medium", label: "Medium" }, | |
| 334 | { value: "high", label: "High", isDefault: true }, | |
| 335 | ], | |
| 336 | supportsFastMode: false, | |
| 337 | supportsThinkingToggle: true, | |
| 338 | contextWindowOptions: [], | |
| 339 | promptInjectedEffortLevels: [], | |
| 340 | }); | |
| 352 | expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual( | |
| 353 | createModelCapabilities({ | |
| 354 | optionDescriptors: [ | |
| 355 | selectDescriptor("reasoning", "Reasoning", [ | |
| 356 | { id: "low", label: "Low" }, | |
| 357 | { id: "medium", label: "Medium" }, | |
| 358 | { id: "high", label: "High", isDefault: true }, | |
| 359 | ]), | |
| 360 | booleanDescriptor("thinking", "Thinking", true), | |
| 361 | ], | |
| 362 | }), | |
| 363 | ); | |
| 341 | 364 | }); |
| 342 | 365 | |
| 343 | 366 | it("prefers the newer model_option effort control over legacy thought_level", () => { |
| 344 | 367 | expect( |
| 345 | 368 | buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), |
| 346 | ).toEqual({ | |
| 347 | reasoningEffortLevels: [ | |
| 348 | { value: "low", label: "Low" }, | |
| 349 | { value: "medium", label: "Medium" }, | |
| 350 | { value: "high", label: "High" }, | |
| 351 | { value: "max", label: "Max", isDefault: true }, | |
| 352 | ], | |
| 353 | supportsFastMode: true, | |
| 354 | supportsThinkingToggle: true, | |
| 355 | contextWindowOptions: [], | |
| 356 | promptInjectedEffortLevels: [], | |
| 357 | }); | |
| 369 | ).toEqual( | |
| 370 | createModelCapabilities({ | |
| 371 | optionDescriptors: [ | |
| 372 | selectDescriptor("reasoning", "Effort", [ | |
| 373 | { id: "low", label: "Low" }, | |
| 374 | { id: "medium", label: "Medium" }, | |
| 375 | { id: "high", label: "High" }, | |
| 376 | { id: "max", label: "Max", isDefault: true }, | |
| 377 | ]), | |
| 378 | booleanDescriptor("fastMode", "Fast", true), | |
| 379 | booleanDescriptor("thinking", "Thinking", true), | |
| 380 | ], | |
| 381 | }), | |
| 382 | ); | |
| 358 | 383 | }); |
| 359 | 384 | }); |
| 360 | 385 | |
| ... | ... | @@ -365,73 +390,39 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { |
| 365 | 390 | slug: "default", |
| 366 | 391 | name: "Auto", |
| 367 | 392 | isCustom: false, |
| 368 | capabilities: { | |
| 369 | reasoningEffortLevels: [], | |
| 370 | supportsFastMode: false, | |
| 371 | supportsThinkingToggle: false, | |
| 372 | contextWindowOptions: [], | |
| 373 | promptInjectedEffortLevels: [], | |
| 374 | }, | |
| 393 | capabilities: emptyCapabilities, | |
| 375 | 394 | }, |
| 376 | 395 | { |
| 377 | 396 | slug: "composer-2", |
| 378 | 397 | name: "Composer 2", |
| 379 | 398 | isCustom: false, |
| 380 | capabilities: { | |
| 381 | reasoningEffortLevels: [], | |
| 382 | supportsFastMode: true, | |
| 383 | supportsThinkingToggle: false, | |
| 384 | contextWindowOptions: [], | |
| 385 | promptInjectedEffortLevels: [], | |
| 386 | }, | |
| 399 | capabilities: createModelCapabilities({ | |
| 400 | optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], | |
| 401 | }), | |
| 387 | 402 | }, |
| 388 | 403 | { |
| 389 | 404 | slug: "gpt-5.4", |
| 390 | 405 | name: "GPT-5.4", |
| 391 | 406 | isCustom: false, |
| 392 | capabilities: { | |
| 393 | reasoningEffortLevels: [], | |
| 394 | supportsFastMode: false, | |
| 395 | supportsThinkingToggle: false, | |
| 396 | contextWindowOptions: [], | |
| 397 | promptInjectedEffortLevels: [], | |
| 398 | }, | |
| 407 | capabilities: emptyCapabilities, | |
| 399 | 408 | }, |
| 400 | 409 | { |
| 401 | 410 | slug: "claude-sonnet-4-6", |
| 402 | 411 | name: "Sonnet 4.6", |
| 403 | 412 | isCustom: false, |
| 404 | capabilities: { | |
| 405 | reasoningEffortLevels: [], | |
| 406 | supportsFastMode: false, | |
| 407 | supportsThinkingToggle: false, | |
| 408 | contextWindowOptions: [], | |
| 409 | promptInjectedEffortLevels: [], | |
| 410 | }, | |
| 413 | capabilities: emptyCapabilities, | |
| 411 | 414 | }, |
| 412 | 415 | { |
| 413 | 416 | slug: "claude-opus-4-6", |
| 414 | 417 | name: "Opus 4.6", |
| 415 | 418 | isCustom: false, |
| 416 | capabilities: { | |
| 417 | reasoningEffortLevels: [], | |
| 418 | supportsFastMode: false, | |
| 419 | supportsThinkingToggle: false, | |
| 420 | contextWindowOptions: [], | |
| 421 | promptInjectedEffortLevels: [], | |
| 422 | }, | |
| 419 | capabilities: emptyCapabilities, | |
| 423 | 420 | }, |
| 424 | 421 | { |
| 425 | 422 | slug: "gpt-5.3-codex-spark", |
| 426 | 423 | name: "Codex 5.3 Spark", |
| 427 | 424 | isCustom: false, |
| 428 | capabilities: { | |
| 429 | reasoningEffortLevels: [], | |
| 430 | supportsFastMode: false, | |
| 431 | supportsThinkingToggle: false, | |
| 432 | contextWindowOptions: [], | |
| 433 | promptInjectedEffortLevels: [], | |
| 434 | }, | |
| 425 | capabilities: emptyCapabilities, | |
| 435 | 426 | }, |
| 436 | 427 | ]); |
| 437 | 428 | }); |
| ... | ... | @@ -645,11 +636,11 @@ describe("resolveCursorAcpBaseModelId", () => { |
| 645 | 636 | describe("resolveCursorAcpConfigUpdates", () => { |
| 646 | 637 | it("maps Cursor model options onto separate ACP config option updates", () => { |
| 647 | 638 | expect( |
| 648 | resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { | |
| 649 | reasoning: "xhigh", | |
| 650 | fastMode: true, | |
| 651 | contextWindow: "1m", | |
| 652 | }), | |
| 639 | resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ | |
| 640 | { id: "reasoning", value: "xhigh" }, | |
| 641 | { id: "fastMode", value: true }, | |
| 642 | { id: "contextWindow", value: "1m" }, | |
| 643 | ]), | |
| 653 | 644 | ).toEqual([ |
| 654 | 645 | { configId: "reasoning", value: "extra-high" }, |
| 655 | 646 | { configId: "context", value: "1m" }, |
| ... | ... | @@ -659,26 +650,26 @@ describe("resolveCursorAcpConfigUpdates", () => { |
| 659 | 650 | |
| 660 | 651 | it("maps boolean thinking toggles when the model exposes them separately", () => { |
| 661 | 652 | expect( |
| 662 | resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { | |
| 663 | thinking: false, | |
| 664 | }), | |
| 653 | resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [ | |
| 654 | { id: "thinking", value: false }, | |
| 655 | ]), | |
| 665 | 656 | ).toEqual([{ configId: "thinking", value: false }]); |
| 666 | 657 | }); |
| 667 | 658 | |
| 668 | 659 | it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { |
| 669 | 660 | expect( |
| 670 | resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { | |
| 671 | fastMode: false, | |
| 672 | }), | |
| 661 | resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ | |
| 662 | { id: "fastMode", value: false }, | |
| 663 | ]), | |
| 673 | 664 | ).toEqual([{ configId: "fast", value: "false" }]); |
| 674 | 665 | }); |
| 675 | 666 | |
| 676 | 667 | it("writes Cursor effort changes through the newer model_option config when available", () => { |
| 677 | 668 | expect( |
| 678 | resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { | |
| 679 | reasoning: "max", | |
| 680 | thinking: false, | |
| 681 | }), | |
| 669 | resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, [ | |
| 670 | { id: "reasoning", value: "max" }, | |
| 671 | { id: "thinking", value: false }, | |
| 672 | ]), | |
| 682 | 673 | ).toEqual([ |
| 683 | 674 | { configId: "effort", value: "max" }, |
| 684 | 675 | { configId: "thinking", value: "false" }, |
apps/server/src/provider/Layers/CursorProvider.ts+113-31| ... | ... | @@ -3,9 +3,9 @@ import * as nodeOs from "node:os"; |
| 3 | 3 | import * as nodePath from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import type { |
| 6 | CursorModelOptions, | |
| 7 | 6 | CursorSettings, |
| 8 | 7 | ModelCapabilities, |
| 8 | ProviderOptionSelection, | |
| 9 | 9 | ServerProvider, |
| 10 | 10 | ServerProviderAuth, |
| 11 | 11 | ServerProviderModel, |
| ... | ... | @@ -15,8 +15,15 @@ import type { |
| 15 | 15 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 16 | 16 | import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; |
| 17 | 17 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | import { | |
| 19 | createModelCapabilities, | |
| 20 | getProviderOptionBooleanSelectionValue, | |
| 21 | getProviderOptionStringSelectionValue, | |
| 22 | } from "@t3tools/shared/model"; | |
| 18 | 23 | |
| 19 | 24 | import { |
| 25 | buildBooleanOptionDescriptor, | |
| 26 | buildSelectOptionDescriptor, | |
| 20 | 27 | buildServerProvider, |
| 21 | 28 | collectStreamAsString, |
| 22 | 29 | isCommandMissingCause, |
| ... | ... | @@ -29,13 +36,14 @@ import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; |
| 29 | 36 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 30 | 37 | |
| 31 | 38 | const PROVIDER = "cursor" as const; |
| 32 | const EMPTY_CAPABILITIES: ModelCapabilities = { | |
| 33 | reasoningEffortLevels: [], | |
| 34 | supportsFastMode: false, | |
| 35 | supportsThinkingToggle: false, | |
| 36 | contextWindowOptions: [], | |
| 37 | promptInjectedEffortLevels: [], | |
| 38 | }; | |
| 39 | const CURSOR_PRESENTATION = { | |
| 40 | displayName: "Cursor", | |
| 41 | badgeLabel: "Early Access", | |
| 42 | showInteractionModeToggle: true, | |
| 43 | } as const; | |
| 44 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 45 | optionDescriptors: [], | |
| 46 | }); | |
| 39 | 47 | |
| 40 | 48 | const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; |
| 41 | 49 | const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; |
| ... | ... | @@ -55,6 +63,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser |
| 55 | 63 | if (!cursorSettings.enabled) { |
| 56 | 64 | return buildServerProvider({ |
| 57 | 65 | provider: PROVIDER, |
| 66 | presentation: CURSOR_PRESENTATION, | |
| 58 | 67 | enabled: false, |
| 59 | 68 | checkedAt, |
| 60 | 69 | models, |
| ... | ... | @@ -70,6 +79,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser |
| 70 | 79 | |
| 71 | 80 | return buildServerProvider({ |
| 72 | 81 | provider: PROVIDER, |
| 82 | presentation: CURSOR_PRESENTATION, | |
| 73 | 83 | enabled: true, |
| 74 | 84 | checkedAt, |
| 75 | 85 | models, |
| ... | ... | @@ -198,6 +208,28 @@ function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): |
| 198 | 208 | return values.has("true") && values.has("false"); |
| 199 | 209 | } |
| 200 | 210 | |
| 211 | function getBooleanCurrentValue( | |
| 212 | option: EffectAcpSchema.SessionConfigOption | undefined, | |
| 213 | ): boolean | undefined { | |
| 214 | if (!option) { | |
| 215 | return undefined; | |
| 216 | } | |
| 217 | if (option.type === "boolean") { | |
| 218 | return option.currentValue; | |
| 219 | } | |
| 220 | if (option.type !== "select") { | |
| 221 | return undefined; | |
| 222 | } | |
| 223 | const normalized = option.currentValue?.trim().toLowerCase(); | |
| 224 | if (normalized === "true") { | |
| 225 | return true; | |
| 226 | } | |
| 227 | if (normalized === "false") { | |
| 228 | return false; | |
| 229 | } | |
| 230 | return undefined; | |
| 231 | } | |
| 232 | ||
| 201 | 233 | export function buildCursorCapabilitiesFromConfigOptions( |
| 202 | 234 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, |
| 203 | 235 | ): ModelCapabilities { |
| ... | ... | @@ -251,14 +283,60 @@ export function buildCursorCapabilitiesFromConfigOptions( |
| 251 | 283 | const thinkingOption = configOptions.find( |
| 252 | 284 | (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), |
| 253 | 285 | ); |
| 286 | const fastCurrentValue = getBooleanCurrentValue(fastOption); | |
| 287 | const thinkingCurrentValue = getBooleanCurrentValue(thinkingOption); | |
| 288 | const optionDescriptors = [ | |
| 289 | ...(reasoningEffortLevels.length > 0 | |
| 290 | ? [ | |
| 291 | buildSelectOptionDescriptor({ | |
| 292 | id: "reasoning", | |
| 293 | label: reasoningConfig?.name?.trim() || "Reasoning", | |
| 294 | options: reasoningEffortLevels, | |
| 295 | }), | |
| 296 | ] | |
| 297 | : []), | |
| 298 | ...(contextWindowOptions.length > 0 | |
| 299 | ? [ | |
| 300 | buildSelectOptionDescriptor({ | |
| 301 | id: "contextWindow", | |
| 302 | label: contextOption?.name?.trim() || "Context Window", | |
| 303 | options: contextWindowOptions, | |
| 304 | }), | |
| 305 | ] | |
| 306 | : []), | |
| 307 | ...(fastOption && isBooleanLikeConfigOption(fastOption) | |
| 308 | ? [ | |
| 309 | typeof fastCurrentValue === "boolean" | |
| 310 | ? buildBooleanOptionDescriptor({ | |
| 311 | id: "fastMode", | |
| 312 | label: fastOption.name?.trim() || "Fast Mode", | |
| 313 | currentValue: fastCurrentValue, | |
| 314 | }) | |
| 315 | : buildBooleanOptionDescriptor({ | |
| 316 | id: "fastMode", | |
| 317 | label: fastOption.name?.trim() || "Fast Mode", | |
| 318 | }), | |
| 319 | ] | |
| 320 | : []), | |
| 321 | ...(thinkingOption && isBooleanLikeConfigOption(thinkingOption) | |
| 322 | ? [ | |
| 323 | typeof thinkingCurrentValue === "boolean" | |
| 324 | ? buildBooleanOptionDescriptor({ | |
| 325 | id: "thinking", | |
| 326 | label: thinkingOption.name?.trim() || "Thinking", | |
| 327 | currentValue: thinkingCurrentValue, | |
| 328 | }) | |
| 329 | : buildBooleanOptionDescriptor({ | |
| 330 | id: "thinking", | |
| 331 | label: thinkingOption.name?.trim() || "Thinking", | |
| 332 | }), | |
| 333 | ] | |
| 334 | : []), | |
| 335 | ]; | |
| 254 | 336 | |
| 255 | return { | |
| 256 | reasoningEffortLevels, | |
| 257 | supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, | |
| 258 | supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, | |
| 259 | contextWindowOptions, | |
| 260 | promptInjectedEffortLevels: [], | |
| 261 | }; | |
| 337 | return createModelCapabilities({ | |
| 338 | optionDescriptors, | |
| 339 | }); | |
| 262 | 340 | } |
| 263 | 341 | |
| 264 | 342 | function buildCursorDiscoveredModels( |
| ... | ... | @@ -282,13 +360,7 @@ function buildCursorDiscoveredModels( |
| 282 | 360 | } |
| 283 | 361 | |
| 284 | 362 | function hasCursorModelCapabilities(model: Pick<ServerProviderModel, "capabilities">): boolean { |
| 285 | return ( | |
| 286 | (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || | |
| 287 | model.capabilities?.supportsFastMode === true || | |
| 288 | model.capabilities?.supportsThinkingToggle === true || | |
| 289 | (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || | |
| 290 | (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 | |
| 291 | ); | |
| 363 | return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | |
| 292 | 364 | } |
| 293 | 365 | |
| 294 | 366 | export function buildCursorDiscoveredModelsFromConfigOptions( |
| ... | ... | @@ -387,7 +459,7 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s |
| 387 | 459 | |
| 388 | 460 | export function resolveCursorAcpConfigUpdates( |
| 389 | 461 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, |
| 390 | modelOptions: CursorModelOptions | null | undefined, | |
| 462 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 391 | 463 | ): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { |
| 392 | 464 | if (!configOptions || configOptions.length === 0) { |
| 393 | 465 | return []; |
| ... | ... | @@ -396,7 +468,9 @@ export function resolveCursorAcpConfigUpdates( |
| 396 | 468 | const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; |
| 397 | 469 | |
| 398 | 470 | const reasoningOption = findCursorEffortConfigOption(configOptions); |
| 399 | const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); | |
| 471 | const requestedReasoning = normalizeCursorReasoningValue( | |
| 472 | getProviderOptionStringSelectionValue(selections, "reasoning"), | |
| 473 | ); | |
| 400 | 474 | if (reasoningOption && requestedReasoning) { |
| 401 | 475 | const value = findCursorSelectOptionValue(reasoningOption, (option) => { |
| 402 | 476 | const normalizedValue = normalizeCursorReasoningValue(option.value); |
| ... | ... | @@ -411,14 +485,15 @@ export function resolveCursorAcpConfigUpdates( |
| 411 | 485 | const contextOption = configOptions.find( |
| 412 | 486 | (option) => option.category === "model_config" && isCursorContextConfigOption(option), |
| 413 | 487 | ); |
| 414 | if (contextOption && modelOptions?.contextWindow) { | |
| 488 | const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); | |
| 489 | if (contextOption && requestedContextWindow) { | |
| 415 | 490 | const value = findCursorSelectOptionValue( |
| 416 | 491 | contextOption, |
| 417 | 492 | (option) => |
| 418 | 493 | normalizeCursorConfigOptionToken(option.value) === |
| 419 | normalizeCursorConfigOptionToken(modelOptions.contextWindow) || | |
| 494 | normalizeCursorConfigOptionToken(requestedContextWindow) || | |
| 420 | 495 | normalizeCursorConfigOptionToken(option.name) === |
| 421 | normalizeCursorConfigOptionToken(modelOptions.contextWindow), | |
| 496 | normalizeCursorConfigOptionToken(requestedContextWindow), | |
| 422 | 497 | ); |
| 423 | 498 | if (value) { |
| 424 | 499 | updates.push({ configId: contextOption.id, value }); |
| ... | ... | @@ -428,8 +503,9 @@ export function resolveCursorAcpConfigUpdates( |
| 428 | 503 | const fastOption = configOptions.find( |
| 429 | 504 | (option) => option.category === "model_config" && isCursorFastConfigOption(option), |
| 430 | 505 | ); |
| 431 | if (fastOption && typeof modelOptions?.fastMode === "boolean") { | |
| 432 | const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); | |
| 506 | const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); | |
| 507 | if (fastOption && typeof requestedFastMode === "boolean") { | |
| 508 | const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); | |
| 433 | 509 | if (value !== undefined) { |
| 434 | 510 | updates.push({ configId: fastOption.id, value }); |
| 435 | 511 | } |
| ... | ... | @@ -438,8 +514,9 @@ export function resolveCursorAcpConfigUpdates( |
| 438 | 514 | const thinkingOption = configOptions.find( |
| 439 | 515 | (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), |
| 440 | 516 | ); |
| 441 | if (thinkingOption && typeof modelOptions?.thinking === "boolean") { | |
| 442 | const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); | |
| 517 | const requestedThinking = getProviderOptionBooleanSelectionValue(selections, "thinking"); | |
| 518 | if (thinkingOption && typeof requestedThinking === "boolean") { | |
| 519 | const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); | |
| 443 | 520 | if (value !== undefined) { |
| 444 | 521 | updates.push({ configId: thinkingOption.id, value }); |
| 445 | 522 | } |
| ... | ... | @@ -610,6 +687,7 @@ export function buildCursorProviderSnapshot(input: { |
| 610 | 687 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); |
| 611 | 688 | return buildServerProvider({ |
| 612 | 689 | provider: PROVIDER, |
| 690 | presentation: CURSOR_PRESENTATION, | |
| 613 | 691 | enabled: input.cursorSettings.enabled, |
| 614 | 692 | checkedAt: input.checkedAt, |
| 615 | 693 | models: providerModelsFromSettings( |
| ... | ... | @@ -962,6 +1040,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( |
| 962 | 1040 | if (!cursorSettings.enabled) { |
| 963 | 1041 | return buildServerProvider({ |
| 964 | 1042 | provider: PROVIDER, |
| 1043 | presentation: CURSOR_PRESENTATION, | |
| 965 | 1044 | enabled: false, |
| 966 | 1045 | checkedAt, |
| 967 | 1046 | models: fallbackModels, |
| ... | ... | @@ -985,6 +1064,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( |
| 985 | 1064 | const error = aboutProbe.failure; |
| 986 | 1065 | return buildServerProvider({ |
| 987 | 1066 | provider: PROVIDER, |
| 1067 | presentation: CURSOR_PRESENTATION, | |
| 988 | 1068 | enabled: cursorSettings.enabled, |
| 989 | 1069 | checkedAt, |
| 990 | 1070 | models: fallbackModels, |
| ... | ... | @@ -1003,6 +1083,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( |
| 1003 | 1083 | if (Option.isNone(aboutProbe.success)) { |
| 1004 | 1084 | return buildServerProvider({ |
| 1005 | 1085 | provider: PROVIDER, |
| 1086 | presentation: CURSOR_PRESENTATION, | |
| 1006 | 1087 | enabled: cursorSettings.enabled, |
| 1007 | 1088 | checkedAt, |
| 1008 | 1089 | models: fallbackModels, |
| ... | ... | @@ -1025,6 +1106,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( |
| 1025 | 1106 | if (parameterizedModelPickerUnsupportedMessage) { |
| 1026 | 1107 | return buildServerProvider({ |
| 1027 | 1108 | provider: PROVIDER, |
| 1109 | presentation: CURSOR_PRESENTATION, | |
| 1028 | 1110 | enabled: cursorSettings.enabled, |
| 1029 | 1111 | checkedAt, |
| 1030 | 1112 | models: fallbackModels, |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+3-2| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; |
| 15 | 15 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; |
| 16 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 16 | 17 | |
| 17 | 18 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 18 | 19 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -1146,11 +1147,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { |
| 1146 | 1147 | |
| 1147 | 1148 | const agent = |
| 1148 | 1149 | input.modelSelection?.provider === PROVIDER |
| 1149 | ? input.modelSelection.options?.agent | |
| 1150 | ? getModelSelectionStringOptionValue(input.modelSelection, "agent") | |
| 1150 | 1151 | : undefined; |
| 1151 | 1152 | const variant = |
| 1152 | 1153 | input.modelSelection?.provider === PROVIDER |
| 1153 | ? input.modelSelection.options?.variant | |
| 1154 | ? getModelSelectionStringOptionValue(input.modelSelection, "variant") | |
| 1154 | 1155 | : undefined; |
| 1155 | 1156 | |
| 1156 | 1157 | context.activeTurnId = turnId; |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+8-6| ... | ... | @@ -176,14 +176,16 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 176 | 176 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); |
| 177 | 177 | |
| 178 | 178 | assert.ok(model); |
| 179 | assert.equal( | |
| 180 | model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value, | |
| 181 | "medium", | |
| 179 | const variantDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 180 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | |
| 182 | 181 | ); |
| 183 | assert.equal( | |
| 184 | model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value, | |
| 185 | "build", | |
| 182 | assert.ok(variantDescriptor && variantDescriptor.type === "select"); | |
| 183 | assert.equal(variantDescriptor.options.find((option) => option.isDefault)?.id, "medium"); | |
| 184 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | |
| 185 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | |
| 186 | 186 | ); |
| 187 | assert.ok(agentDescriptor && agentDescriptor.type === "select"); | |
| 188 | assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); | |
| 187 | 189 | }), |
| 188 | 190 | ); |
| 189 | 191 | }); |
apps/server/src/provider/Layers/OpenCodeProvider.ts+49-22| ... | ... | @@ -6,6 +6,8 @@ import type { |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; |
| 8 | 8 | |
| 9 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 10 | ||
| 9 | 11 | import { ServerConfig } from "../../config.ts"; |
| 10 | 12 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 11 | 13 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| ... | ... | @@ -25,6 +27,10 @@ import { |
| 25 | 27 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; |
| 26 | 28 | |
| 27 | 29 | const PROVIDER = "opencode" as const; |
| 30 | const OPENCODE_PRESENTATION = { | |
| 31 | displayName: "OpenCode", | |
| 32 | showInteractionModeToggle: false, | |
| 33 | } as const; | |
| 28 | 34 | const MINIMUM_OPENCODE_VERSION = "1.14.19"; |
| 29 | 35 | |
| 30 | 36 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ |
| ... | ... | @@ -159,13 +165,9 @@ function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { |
| 159 | 165 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; |
| 160 | 166 | } |
| 161 | 167 | |
| 162 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { | |
| 163 | reasoningEffortLevels: [], | |
| 164 | supportsFastMode: false, | |
| 165 | supportsThinkingToggle: false, | |
| 166 | contextWindowOptions: [], | |
| 167 | promptInjectedEffortLevels: [], | |
| 168 | }; | |
| 168 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 169 | optionDescriptors: [], | |
| 170 | }); | |
| 169 | 171 | |
| 170 | 172 | function openCodeCapabilitiesForModel(input: { |
| 171 | 173 | readonly providerID: string; |
| ... | ... | @@ -174,27 +176,46 @@ function openCodeCapabilitiesForModel(input: { |
| 174 | 176 | }): ModelCapabilities { |
| 175 | 177 | const variantValues = Object.keys(input.model.variants ?? {}); |
| 176 | 178 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); |
| 177 | const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) => | |
| 178 | Object.assign( | |
| 179 | { value, label: titleCaseSlug(value) }, | |
| 180 | defaultVariant === value ? { isDefault: true } : {}, | |
| 181 | ), | |
| 179 | const variantOptions = variantValues.map((value) => | |
| 180 | defaultVariant === value | |
| 181 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } | |
| 182 | : { id: value, label: titleCaseSlug(value) }, | |
| 182 | 183 | ); |
| 183 | 184 | const primaryAgents = input.agents.filter( |
| 184 | 185 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), |
| 185 | 186 | ); |
| 186 | 187 | const defaultAgent = inferDefaultAgent(primaryAgents); |
| 187 | const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) => | |
| 188 | Object.assign( | |
| 189 | { value: agent.name, label: titleCaseSlug(agent.name) }, | |
| 190 | defaultAgent === agent.name ? { isDefault: true } : {}, | |
| 191 | ), | |
| 188 | const agentOptions = primaryAgents.map((agent) => | |
| 189 | defaultAgent === agent.name | |
| 190 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } | |
| 191 | : { id: agent.name, label: titleCaseSlug(agent.name) }, | |
| 192 | 192 | ); |
| 193 | return { | |
| 194 | ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 195 | ...(variantOptions.length > 0 ? { variantOptions } : {}), | |
| 196 | ...(agentOptions.length > 0 ? { agentOptions } : {}), | |
| 197 | }; | |
| 193 | return createModelCapabilities({ | |
| 194 | optionDescriptors: [ | |
| 195 | ...(variantOptions.length > 0 | |
| 196 | ? [ | |
| 197 | { | |
| 198 | id: "variant", | |
| 199 | label: "Variant", | |
| 200 | type: "select" as const, | |
| 201 | options: variantOptions, | |
| 202 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), | |
| 203 | }, | |
| 204 | ] | |
| 205 | : []), | |
| 206 | ...(agentOptions.length > 0 | |
| 207 | ? [ | |
| 208 | { | |
| 209 | id: "agent", | |
| 210 | label: "Agent", | |
| 211 | type: "select" as const, | |
| 212 | options: agentOptions, | |
| 213 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), | |
| 214 | }, | |
| 215 | ] | |
| 216 | : []), | |
| 217 | ], | |
| 218 | }); | |
| 198 | 219 | } |
| 199 | 220 | |
| 200 | 221 | function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerProviderModel> { |
| ... | ... | @@ -242,6 +263,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server |
| 242 | 263 | if (!openCodeSettings.enabled) { |
| 243 | 264 | return buildServerProvider({ |
| 244 | 265 | provider: PROVIDER, |
| 266 | presentation: OPENCODE_PRESENTATION, | |
| 245 | 267 | enabled: false, |
| 246 | 268 | checkedAt, |
| 247 | 269 | models, |
| ... | ... | @@ -260,6 +282,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server |
| 260 | 282 | |
| 261 | 283 | return buildServerProvider({ |
| 262 | 284 | provider: PROVIDER, |
| 285 | presentation: OPENCODE_PRESENTATION, | |
| 263 | 286 | enabled: true, |
| 264 | 287 | checkedAt, |
| 265 | 288 | models, |
| ... | ... | @@ -296,6 +319,7 @@ export const OpenCodeProviderLive = Layer.effect( |
| 296 | 319 | }); |
| 297 | 320 | return buildServerProvider({ |
| 298 | 321 | provider: PROVIDER, |
| 322 | presentation: OPENCODE_PRESENTATION, | |
| 299 | 323 | enabled: input.settings.enabled, |
| 300 | 324 | checkedAt, |
| 301 | 325 | models: providerModelsFromSettings( |
| ... | ... | @@ -317,6 +341,7 @@ export const OpenCodeProviderLive = Layer.effect( |
| 317 | 341 | if (!input.settings.enabled) { |
| 318 | 342 | return buildServerProvider({ |
| 319 | 343 | provider: PROVIDER, |
| 344 | presentation: OPENCODE_PRESENTATION, | |
| 320 | 345 | enabled: false, |
| 321 | 346 | checkedAt, |
| 322 | 347 | models: providerModelsFromSettings( |
| ... | ... | @@ -368,6 +393,7 @@ export const OpenCodeProviderLive = Layer.effect( |
| 368 | 393 | if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { |
| 369 | 394 | return buildServerProvider({ |
| 370 | 395 | provider: PROVIDER, |
| 396 | presentation: OPENCODE_PRESENTATION, | |
| 371 | 397 | enabled: input.settings.enabled, |
| 372 | 398 | checkedAt, |
| 373 | 399 | models: providerModelsFromSettings( |
| ... | ... | @@ -433,6 +459,7 @@ export const OpenCodeProviderLive = Layer.effect( |
| 433 | 459 | const connectedCount = inventoryExit.value.providerList.connected.length; |
| 434 | 460 | return buildServerProvider({ |
| 435 | 461 | provider: PROVIDER, |
| 462 | presentation: OPENCODE_PRESENTATION, | |
| 436 | 463 | enabled: true, |
| 437 | 464 | checkedAt, |
| 438 | 465 | models, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+7-6| ... | ... | @@ -19,6 +19,7 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 19 | 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 20 | 20 | import { CursorAdapter } from "../Services/CursorAdapter.ts"; |
| 21 | 21 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; |
| 22 | import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts"; | |
| 22 | 23 | |
| 23 | 24 | export interface ProviderAdapterRegistryLiveOptions { |
| 24 | 25 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; |
| ... | ... | @@ -31,12 +32,12 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun |
| 31 | 32 | const adapters = |
| 32 | 33 | options?.adapters !== undefined |
| 33 | 34 | ? options.adapters |
| 34 | : [ | |
| 35 | yield* CodexAdapter, | |
| 36 | yield* ClaudeAdapter, | |
| 37 | yield* OpenCodeAdapter, | |
| 38 | ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []), | |
| 39 | ]; | |
| 35 | : createBuiltInAdapterList({ | |
| 36 | codex: yield* CodexAdapter, | |
| 37 | claudeAgent: yield* ClaudeAdapter, | |
| 38 | opencode: yield* OpenCodeAdapter, | |
| 39 | ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}), | |
| 40 | }); | |
| 40 | 41 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 41 | 42 | |
| 42 | 43 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+61-32| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | import * as PlatformError from "effect/PlatformError"; |
| 12 | 12 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 13 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 14 | 15 | |
| 15 | 16 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 16 | 17 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; |
| ... | ... | @@ -29,6 +30,30 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 29 | 30 | |
| 30 | 31 | const encoder = new TextEncoder(); |
| 31 | 32 | |
| 33 | function selectDescriptor( | |
| 34 | id: string, | |
| 35 | label: string, | |
| 36 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 37 | ) { | |
| 38 | return { | |
| 39 | id, | |
| 40 | label, | |
| 41 | type: "select" as const, | |
| 42 | options: [...options], | |
| 43 | ...(options.find((option) => option.isDefault)?.id | |
| 44 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 45 | : {}), | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | function booleanDescriptor(id: string, label: string) { | |
| 50 | return { | |
| 51 | id, | |
| 52 | label, | |
| 53 | type: "boolean" as const, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 32 | 57 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { |
| 33 | 58 | return ChildProcessSpawner.makeHandle({ |
| 34 | 59 | pid: ChildProcessSpawner.ProcessId(1), |
| ... | ... | @@ -88,16 +113,15 @@ function failingSpawnerLayer(description: string) { |
| 88 | 113 | ); |
| 89 | 114 | } |
| 90 | 115 | |
| 91 | const codexModelCapabilities = { | |
| 92 | reasoningEffortLevels: [ | |
| 93 | { value: "high", label: "High", isDefault: true }, | |
| 94 | { value: "low", label: "Low" }, | |
| 116 | const codexModelCapabilities = createModelCapabilities({ | |
| 117 | optionDescriptors: [ | |
| 118 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 119 | { id: "high", label: "High", isDefault: true }, | |
| 120 | { id: "low", label: "Low" }, | |
| 121 | ]), | |
| 122 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 95 | 123 | ], |
| 96 | supportsFastMode: true, | |
| 97 | supportsThinkingToggle: false, | |
| 98 | contextWindowOptions: [], | |
| 99 | promptInjectedEffortLevels: [], | |
| 100 | } satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>; | |
| 124 | }) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>; | |
| 101 | 125 | |
| 102 | 126 | function makeCodexProbeSnapshot( |
| 103 | 127 | input: Partial<CodexAppServerProviderSnapshot> = {}, |
| ... | ... | @@ -330,13 +354,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 330 | 354 | slug: "claude-opus-4-6", |
| 331 | 355 | name: "Opus 4.6", |
| 332 | 356 | isCustom: false, |
| 333 | capabilities: { | |
| 334 | reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], | |
| 335 | supportsFastMode: true, | |
| 336 | supportsThinkingToggle: true, | |
| 337 | contextWindowOptions: [], | |
| 338 | promptInjectedEffortLevels: [], | |
| 339 | }, | |
| 357 | capabilities: createModelCapabilities({ | |
| 358 | optionDescriptors: [ | |
| 359 | selectDescriptor("reasoning", "Reasoning", [ | |
| 360 | { id: "high", label: "High", isDefault: true }, | |
| 361 | ]), | |
| 362 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 363 | booleanDescriptor("thinking", "Thinking"), | |
| 364 | ], | |
| 365 | }), | |
| 340 | 366 | }, |
| 341 | 367 | ], |
| 342 | 368 | slashCommands: [], |
| ... | ... | @@ -367,13 +393,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 367 | 393 | slug: "claude-opus-4-6", |
| 368 | 394 | name: "Opus 4.6", |
| 369 | 395 | isCustom: false, |
| 370 | capabilities: { | |
| 371 | reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], | |
| 372 | supportsFastMode: true, | |
| 373 | supportsThinkingToggle: true, | |
| 374 | contextWindowOptions: [], | |
| 375 | promptInjectedEffortLevels: [], | |
| 376 | }, | |
| 396 | capabilities: createModelCapabilities({ | |
| 397 | optionDescriptors: [ | |
| 398 | selectDescriptor("reasoning", "Reasoning", [ | |
| 399 | { id: "high", label: "High", isDefault: true }, | |
| 400 | ]), | |
| 401 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 402 | booleanDescriptor("thinking", "Thinking"), | |
| 403 | ], | |
| 404 | }), | |
| 377 | 405 | }, |
| 378 | 406 | ], |
| 379 | 407 | slashCommands: [], |
| ... | ... | @@ -387,13 +415,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 387 | 415 | slug: "claude-opus-4-6", |
| 388 | 416 | name: "Opus 4.6", |
| 389 | 417 | isCustom: false, |
| 390 | capabilities: { | |
| 391 | reasoningEffortLevels: [], | |
| 392 | supportsFastMode: false, | |
| 393 | supportsThinkingToggle: false, | |
| 394 | contextWindowOptions: [], | |
| 395 | promptInjectedEffortLevels: [], | |
| 396 | }, | |
| 418 | capabilities: createModelCapabilities({ | |
| 419 | optionDescriptors: [], | |
| 420 | }), | |
| 397 | 421 | }, |
| 398 | 422 | ], |
| 399 | 423 | } satisfies ServerProvider; |
| ... | ... | @@ -603,9 +627,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 603 | 627 | "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", |
| 604 | 628 | ); |
| 605 | 629 | } |
| 630 | const effortDescriptor = opus47.capabilities.optionDescriptors?.find( | |
| 631 | (descriptor) => descriptor.type === "select" && descriptor.id === "effort", | |
| 632 | ); | |
| 606 | 633 | assert.deepStrictEqual( |
| 607 | opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), | |
| 608 | { value: "xhigh", label: "Extra High", isDefault: true }, | |
| 634 | effortDescriptor?.type === "select" | |
| 635 | ? effortDescriptor.options.find((option) => option.isDefault) | |
| 636 | : undefined, | |
| 637 | { id: "xhigh", label: "Extra High", isDefault: true }, | |
| 609 | 638 | ); |
| 610 | 639 | }).pipe( |
| 611 | 640 | Effect.provide( |
apps/server/src/provider/Layers/ProviderRegistry.ts+9-38| ... | ... | @@ -25,13 +25,8 @@ import { |
| 25 | 25 | resolveProviderStatusCachePath, |
| 26 | 26 | writeProviderStatusCache, |
| 27 | 27 | } from "../providerStatusCache.ts"; |
| 28 | ||
| 29 | type ProviderSnapshotSource = { | |
| 30 | readonly provider: ProviderKind; | |
| 31 | readonly getSnapshot: Effect.Effect<ServerProvider>; | |
| 32 | readonly refresh: Effect.Effect<ServerProvider>; | |
| 33 | readonly streamChanges: Stream.Stream<ServerProvider>; | |
| 34 | }; | |
| 28 | import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts"; | |
| 29 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; | |
| 35 | 30 | |
| 36 | 31 | const loadProviders = ( |
| 37 | 32 | providerSources: ReadonlyArray<ProviderSnapshotSource>, |
| ... | ... | @@ -41,11 +36,7 @@ const loadProviders = ( |
| 41 | 36 | }); |
| 42 | 37 | |
| 43 | 38 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 44 | (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || | |
| 45 | model.capabilities?.supportsFastMode === true || | |
| 46 | model.capabilities?.supportsThinkingToggle === true || | |
| 47 | (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || | |
| 48 | (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0; | |
| 39 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | |
| 49 | 40 | |
| 50 | 41 | const mergeProviderModels = ( |
| 51 | 42 | previousModels: ReadonlyArray<ServerProvider["models"][number]>, |
| ... | ... | @@ -98,32 +89,12 @@ const ProviderRegistryLiveBase = Layer.effect( |
| 98 | 89 | |
| 99 | 90 | const cursorProvider = yield* CursorProvider; |
| 100 | 91 | |
| 101 | const providerSources = [ | |
| 102 | { | |
| 103 | provider: "codex", | |
| 104 | getSnapshot: codexProvider.getSnapshot, | |
| 105 | refresh: codexProvider.refresh, | |
| 106 | streamChanges: codexProvider.streamChanges, | |
| 107 | }, | |
| 108 | { | |
| 109 | provider: "claudeAgent", | |
| 110 | getSnapshot: claudeProvider.getSnapshot, | |
| 111 | refresh: claudeProvider.refresh, | |
| 112 | streamChanges: claudeProvider.streamChanges, | |
| 113 | }, | |
| 114 | { | |
| 115 | provider: "opencode", | |
| 116 | getSnapshot: openCodeProvider.getSnapshot, | |
| 117 | refresh: openCodeProvider.refresh, | |
| 118 | streamChanges: openCodeProvider.streamChanges, | |
| 119 | }, | |
| 120 | { | |
| 121 | provider: "cursor", | |
| 122 | getSnapshot: cursorProvider.getSnapshot, | |
| 123 | refresh: cursorProvider.refresh, | |
| 124 | streamChanges: cursorProvider.streamChanges, | |
| 125 | }, | |
| 126 | ] satisfies ReadonlyArray<ProviderSnapshotSource>; | |
| 92 | const providerSources = createBuiltInProviderSources({ | |
| 93 | codex: codexProvider, | |
| 94 | claudeAgent: claudeProvider, | |
| 95 | opencode: openCodeProvider, | |
| 96 | cursor: cursorProvider, | |
| 97 | }) satisfies ReadonlyArray<ProviderSnapshotSource>; | |
| 127 | 98 | const activeProviders = PROVIDER_CACHE_IDS; |
| 128 | 99 | const changesPubSub = yield* Effect.acquireRelease( |
| 129 | 100 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), |
apps/server/src/provider/Layers/ProviderService.test.ts+8-14| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | ThreadId, |
| 18 | 18 | TurnId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 20 | 21 | import { it, assert, vi } from "@effect/vitest"; |
| 21 | 22 | |
| 22 | 23 | import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; |
| ... | ... | @@ -860,13 +861,9 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 860 | 861 | provider: "claudeAgent", |
| 861 | 862 | threadId: asThreadId("thread-claude-send-turn"), |
| 862 | 863 | cwd: "/tmp/project-claude-send-turn", |
| 863 | modelSelection: { | |
| 864 | provider: "claudeAgent", | |
| 865 | model: "claude-opus-4-6", | |
| 866 | options: { | |
| 867 | effort: "max", | |
| 868 | }, | |
| 869 | }, | |
| 864 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 865 | { id: "effort", value: "max" }, | |
| 866 | ]), | |
| 870 | 867 | runtimeMode: "full-access", |
| 871 | 868 | }); |
| 872 | 869 | |
| ... | ... | @@ -893,13 +890,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 893 | 890 | }; |
| 894 | 891 | assert.equal(startPayload.provider, "claudeAgent"); |
| 895 | 892 | assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); |
| 896 | assert.deepEqual(startPayload.modelSelection, { | |
| 897 | provider: "claudeAgent", | |
| 898 | model: "claude-opus-4-6", | |
| 899 | options: { | |
| 900 | effort: "max", | |
| 901 | }, | |
| 902 | }); | |
| 893 | assert.deepEqual( | |
| 894 | startPayload.modelSelection, | |
| 895 | createModelSelection("claudeAgent", "claude-opus-4-6", [{ id: "effort", value: "max" }]), | |
| 896 | ); | |
| 903 | 897 | assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); |
| 904 | 898 | assert.equal(startPayload.threadId, initial.threadId); |
| 905 | 899 | } |
apps/server/src/provider/acp/CursorAcpSupport.test.ts+5-5| ... | ... | @@ -99,11 +99,11 @@ describe("applyCursorAcpModelSelection", () => { |
| 99 | 99 | applyCursorAcpModelSelection({ |
| 100 | 100 | runtime, |
| 101 | 101 | model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", |
| 102 | modelOptions: { | |
| 103 | reasoning: "xhigh", | |
| 104 | contextWindow: "1m", | |
| 105 | fastMode: true, | |
| 106 | }, | |
| 102 | selections: [ | |
| 103 | { id: "reasoning", value: "xhigh" }, | |
| 104 | { id: "contextWindow", value: "1m" }, | |
| 105 | { id: "fastMode", value: true }, | |
| 106 | ], | |
| 107 | 107 | mapError: ({ step, configId, cause }) => |
| 108 | 108 | new Error( |
| 109 | 109 | step === "set-config-option" |
apps/server/src/provider/acp/CursorAcpSupport.ts+3-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts"; | |
| 1 | import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; | |
| 2 | 2 | import { Effect, Layer, Scope } from "effect"; |
| 3 | 3 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 4 | import type * as EffectAcpErrors from "effect-acp/errors"; |
| ... | ... | @@ -76,7 +76,7 @@ interface CursorAcpModelSelectionRuntime { |
| 76 | 76 | export function applyCursorAcpModelSelection<E>(input: { |
| 77 | 77 | readonly runtime: CursorAcpModelSelectionRuntime; |
| 78 | 78 | readonly model: string | null | undefined; |
| 79 | readonly modelOptions: CursorModelOptions | null | undefined; | |
| 79 | readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined; | |
| 80 | 80 | readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; |
| 81 | 81 | }): Effect.Effect<void, E> { |
| 82 | 82 | return Effect.gen(function* () { |
| ... | ... | @@ -91,7 +91,7 @@ export function applyCursorAcpModelSelection<E>(input: { |
| 91 | 91 | |
| 92 | 92 | const configUpdates = resolveCursorAcpConfigUpdates( |
| 93 | 93 | yield* input.runtime.getConfigOptions, |
| 94 | input.modelOptions, | |
| 94 | input.selections, | |
| 95 | 95 | ); |
| 96 | 96 | for (const update of configUpdates) { |
| 97 | 97 | yield* input.runtime.setConfigOption(update.configId, update.value).pipe( |
apps/server/src/provider/builtInProviderCatalog.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; | |
| 2 | import type { Stream } from "effect"; | |
| 3 | import type { ProviderAdapterError } from "./Errors.ts"; | |
| 4 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; | |
| 5 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | |
| 6 | ||
| 7 | export type ProviderSnapshotSource = { | |
| 8 | readonly provider: ProviderKind; | |
| 9 | readonly getSnapshot: ServerProviderShape["getSnapshot"]; | |
| 10 | readonly refresh: ServerProviderShape["refresh"]; | |
| 11 | readonly streamChanges: Stream.Stream<ServerProvider>; | |
| 12 | }; | |
| 13 | ||
| 14 | type BuiltInProviderServiceMap = Record<ProviderKind, ServerProviderShape>; | |
| 15 | type BuiltInAdapterMap = { | |
| 16 | readonly codex: ProviderAdapterShape<ProviderAdapterError>; | |
| 17 | readonly claudeAgent: ProviderAdapterShape<ProviderAdapterError>; | |
| 18 | readonly opencode: ProviderAdapterShape<ProviderAdapterError>; | |
| 19 | readonly cursor?: ProviderAdapterShape<ProviderAdapterError>; | |
| 20 | }; | |
| 21 | ||
| 22 | export const BUILT_IN_PROVIDER_ORDER = [ | |
| 23 | "codex", | |
| 24 | "claudeAgent", | |
| 25 | "opencode", | |
| 26 | "cursor", | |
| 27 | ] as const satisfies ReadonlyArray<ProviderKind>; | |
| 28 | ||
| 29 | export function createBuiltInProviderSources( | |
| 30 | services: BuiltInProviderServiceMap, | |
| 31 | ): ReadonlyArray<ProviderSnapshotSource> { | |
| 32 | return BUILT_IN_PROVIDER_ORDER.map((provider) => ({ | |
| 33 | provider, | |
| 34 | getSnapshot: services[provider].getSnapshot, | |
| 35 | refresh: services[provider].refresh, | |
| 36 | streamChanges: services[provider].streamChanges, | |
| 37 | })); | |
| 38 | } | |
| 39 | ||
| 40 | export function createBuiltInAdapterList( | |
| 41 | adapters: BuiltInAdapterMap, | |
| 42 | ): ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>> { | |
| 43 | return [ | |
| 44 | adapters.codex, | |
| 45 | adapters.claudeAgent, | |
| 46 | adapters.opencode, | |
| 47 | ...(adapters.cursor ? [adapters.cursor] : []), | |
| 48 | ]; | |
| 49 | } |
apps/server/src/provider/makeManagedServerProvider.test.ts+14-14| ... | ... | @@ -1,9 +1,21 @@ |
| 1 | 1 | import { describe, it, assert } from "@effect/vitest"; |
| 2 | 2 | import type { ServerProvider } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | 4 | import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; |
| 4 | 5 | |
| 5 | 6 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; |
| 6 | 7 | |
| 8 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | |
| 9 | const fastModeCapabilities = createModelCapabilities({ | |
| 10 | optionDescriptors: [ | |
| 11 | { | |
| 12 | id: "fastMode", | |
| 13 | label: "Fast Mode", | |
| 14 | type: "boolean", | |
| 15 | }, | |
| 16 | ], | |
| 17 | }); | |
| 18 | ||
| 7 | 19 | interface TestSettings { |
| 8 | 20 | readonly enabled: boolean; |
| 9 | 21 | } |
| ... | ... | @@ -43,13 +55,7 @@ const enrichedSnapshot: ServerProvider = { |
| 43 | 55 | slug: "composer-2", |
| 44 | 56 | name: "Composer 2", |
| 45 | 57 | isCustom: false, |
| 46 | capabilities: { | |
| 47 | reasoningEffortLevels: [], | |
| 48 | supportsFastMode: true, | |
| 49 | supportsThinkingToggle: false, | |
| 50 | contextWindowOptions: [], | |
| 51 | promptInjectedEffortLevels: [], | |
| 52 | }, | |
| 58 | capabilities: fastModeCapabilities, | |
| 53 | 59 | }, |
| 54 | 60 | ], |
| 55 | 61 | }; |
| ... | ... | @@ -68,13 +74,7 @@ const enrichedSnapshotSecond: ServerProvider = { |
| 68 | 74 | slug: "gpt-5.4", |
| 69 | 75 | name: "GPT-5.4", |
| 70 | 76 | isCustom: false, |
| 71 | capabilities: { | |
| 72 | reasoningEffortLevels: [], | |
| 73 | supportsFastMode: false, | |
| 74 | supportsThinkingToggle: false, | |
| 75 | contextWindowOptions: [], | |
| 76 | promptInjectedEffortLevels: [], | |
| 77 | }, | |
| 77 | capabilities: emptyCapabilities, | |
| 78 | 78 | }, |
| 79 | 79 | ], |
| 80 | 80 | }; |
apps/server/src/provider/opencodeRuntime.ts-1| ... | ... | @@ -36,7 +36,6 @@ import { NetService } from "@t3tools/shared/Net"; |
| 36 | 36 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 37 | 37 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; |
| 38 | 38 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 39 | ||
| 40 | 39 | export interface OpenCodeServerProcess { |
| 41 | 40 | readonly url: string; |
| 42 | 41 | readonly exitCode: Effect.Effect<number, never>; |
apps/server/src/provider/providerSnapshot.test.ts+19-9| ... | ... | @@ -1,17 +1,27 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import type { ModelCapabilities } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | 4 | |
| 4 | 5 | import { providerModelsFromSettings } from "./providerSnapshot.ts"; |
| 5 | 6 | |
| 6 | const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { | |
| 7 | reasoningEffortLevels: [], | |
| 8 | supportsFastMode: false, | |
| 9 | supportsThinkingToggle: false, | |
| 10 | contextWindowOptions: [], | |
| 11 | promptInjectedEffortLevels: [], | |
| 12 | variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], | |
| 13 | agentOptions: [{ value: "build", label: "Build", isDefault: true }], | |
| 14 | }; | |
| 7 | const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 8 | optionDescriptors: [ | |
| 9 | { | |
| 10 | id: "variant", | |
| 11 | label: "Reasoning", | |
| 12 | type: "select", | |
| 13 | options: [{ id: "medium", label: "Medium", isDefault: true }], | |
| 14 | currentValue: "medium", | |
| 15 | }, | |
| 16 | { | |
| 17 | id: "agent", | |
| 18 | label: "Agent", | |
| 19 | type: "select", | |
| 20 | options: [{ id: "build", label: "Build", isDefault: true }], | |
| 21 | currentValue: "build", | |
| 22 | }, | |
| 23 | ], | |
| 24 | }); | |
| 15 | 25 | |
| 16 | 26 | describe("providerModelsFromSettings", () => { |
| 17 | 27 | it("applies the provided capabilities to custom models", () => { |
apps/server/src/provider/providerSnapshot.ts+55| ... | ... | @@ -30,6 +30,12 @@ export interface ProviderProbeResult { |
| 30 | 30 | readonly message?: string; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | export interface ServerProviderPresentation { | |
| 34 | readonly displayName: string; | |
| 35 | readonly badgeLabel?: string; | |
| 36 | readonly showInteractionModeToggle?: boolean; | |
| 37 | } | |
| 38 | ||
| 33 | 39 | export function nonEmptyTrimmed(value: string | undefined): string | undefined { |
| 34 | 40 | if (!value) return undefined; |
| 35 | 41 | const trimmed = value.trim(); |
| ... | ... | @@ -129,8 +135,52 @@ export function providerModelsFromSettings( |
| 129 | 135 | return [...resolvedBuiltInModels, ...customEntries]; |
| 130 | 136 | } |
| 131 | 137 | |
| 138 | export function buildSelectOptionDescriptor(input: { | |
| 139 | readonly id: string; | |
| 140 | readonly label: string; | |
| 141 | readonly options: | |
| 142 | | ReadonlyArray<{ value: string; label: string; isDefault?: boolean | undefined }> | |
| 143 | | undefined; | |
| 144 | readonly description?: string; | |
| 145 | readonly promptInjectedValues?: ReadonlyArray<string>; | |
| 146 | }) { | |
| 147 | const options = (input.options ?? []).map((option) => | |
| 148 | option.isDefault | |
| 149 | ? { id: option.value, label: option.label, isDefault: true } | |
| 150 | : { id: option.value, label: option.label }, | |
| 151 | ); | |
| 152 | const currentValue = options.find((option) => option.isDefault)?.id; | |
| 153 | return { | |
| 154 | id: input.id, | |
| 155 | label: input.label, | |
| 156 | type: "select" as const, | |
| 157 | options, | |
| 158 | ...(currentValue ? { currentValue } : {}), | |
| 159 | ...(input.description ? { description: input.description } : {}), | |
| 160 | ...(input.promptInjectedValues && input.promptInjectedValues.length > 0 | |
| 161 | ? { promptInjectedValues: [...input.promptInjectedValues] } | |
| 162 | : {}), | |
| 163 | }; | |
| 164 | } | |
| 165 | ||
| 166 | export function buildBooleanOptionDescriptor(input: { | |
| 167 | readonly id: string; | |
| 168 | readonly label: string; | |
| 169 | readonly currentValue?: boolean; | |
| 170 | readonly description?: string; | |
| 171 | }) { | |
| 172 | return { | |
| 173 | id: input.id, | |
| 174 | label: input.label, | |
| 175 | type: "boolean" as const, | |
| 176 | ...(input.description ? { description: input.description } : {}), | |
| 177 | ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}), | |
| 178 | }; | |
| 179 | } | |
| 180 | ||
| 132 | 181 | export function buildServerProvider(input: { |
| 133 | 182 | provider: ServerProvider["provider"]; |
| 183 | presentation: ServerProviderPresentation; | |
| 134 | 184 | enabled: boolean; |
| 135 | 185 | checkedAt: string; |
| 136 | 186 | models: ReadonlyArray<ServerProviderModel>; |
| ... | ... | @@ -140,6 +190,11 @@ export function buildServerProvider(input: { |
| 140 | 190 | }): ServerProvider { |
| 141 | 191 | return { |
| 142 | 192 | provider: input.provider, |
| 193 | displayName: input.presentation.displayName, | |
| 194 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), | |
| 195 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" | |
| 196 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } | |
| 197 | : {}), | |
| 143 | 198 | enabled: input.enabled, |
| 144 | 199 | installed: input.probe.installed, |
| 145 | 200 | version: input.probe.version, |
apps/server/src/provider/providerStatusCache.test.ts+6-21| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import type { ServerProvider } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | 4 | import { assert, it } from "@effect/vitest"; |
| 4 | 5 | import { Effect, FileSystem } from "effect"; |
| 5 | 6 | |
| ... | ... | @@ -10,6 +11,8 @@ import { |
| 10 | 11 | writeProviderStatusCache, |
| 11 | 12 | } from "./providerStatusCache.ts"; |
| 12 | 13 | |
| 14 | const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); | |
| 15 | ||
| 13 | 16 | const makeProvider = ( |
| 14 | 17 | provider: ServerProvider["provider"], |
| 15 | 18 | overrides?: Partial<ServerProvider>, |
| ... | ... | @@ -81,13 +84,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 81 | 84 | slug: "gpt-5-mini", |
| 82 | 85 | name: "GPT-5 Mini", |
| 83 | 86 | isCustom: false, |
| 84 | capabilities: { | |
| 85 | reasoningEffortLevels: [], | |
| 86 | supportsFastMode: false, | |
| 87 | supportsThinkingToggle: false, | |
| 88 | contextWindowOptions: [], | |
| 89 | promptInjectedEffortLevels: [], | |
| 90 | }, | |
| 87 | capabilities: emptyCapabilities, | |
| 91 | 88 | }, |
| 92 | 89 | ], |
| 93 | 90 | message: "Cached message", |
| ... | ... | @@ -106,13 +103,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 106 | 103 | slug: "gpt-5.4", |
| 107 | 104 | name: "GPT-5.4", |
| 108 | 105 | isCustom: false, |
| 109 | capabilities: { | |
| 110 | reasoningEffortLevels: [], | |
| 111 | supportsFastMode: false, | |
| 112 | supportsThinkingToggle: false, | |
| 113 | contextWindowOptions: [], | |
| 114 | promptInjectedEffortLevels: [], | |
| 115 | }, | |
| 106 | capabilities: emptyCapabilities, | |
| 116 | 107 | }, |
| 117 | 108 | ], |
| 118 | 109 | message: "Pending refresh", |
| ... | ... | @@ -131,13 +122,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 131 | 122 | slug: "gpt-5-mini", |
| 132 | 123 | name: "GPT-5 Mini", |
| 133 | 124 | isCustom: false, |
| 134 | capabilities: { | |
| 135 | reasoningEffortLevels: [], | |
| 136 | supportsFastMode: false, | |
| 137 | supportsThinkingToggle: false, | |
| 138 | contextWindowOptions: [], | |
| 139 | promptInjectedEffortLevels: [], | |
| 140 | }, | |
| 125 | capabilities: emptyCapabilities, | |
| 141 | 126 | }, |
| 142 | 127 | ], |
| 143 | 128 | installed: cachedCodex.installed, |
apps/server/src/serverSettings.test.ts+60-39| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; | |
| 2 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | |
| 3 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 3 | 4 | import { assert, it } from "@effect/vitest"; |
| 4 | 5 | import { Effect, FileSystem, Layer, Schema } from "effect"; |
| 5 | 6 | import { ServerConfig } from "./config.ts"; |
| ... | ... | @@ -28,22 +29,40 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 28 | 29 | assert.deepEqual( |
| 29 | 30 | decodePatch({ |
| 30 | 31 | textGenerationModelSelection: { |
| 31 | options: { | |
| 32 | fastMode: false, | |
| 33 | }, | |
| 32 | options: [{ id: "fastMode", value: false }], | |
| 34 | 33 | }, |
| 35 | 34 | }), |
| 36 | 35 | { |
| 37 | 36 | textGenerationModelSelection: { |
| 38 | options: { | |
| 39 | fastMode: false, | |
| 40 | }, | |
| 37 | options: [{ id: "fastMode", value: false }], | |
| 41 | 38 | }, |
| 42 | 39 | }, |
| 43 | 40 | ); |
| 44 | 41 | }), |
| 45 | 42 | ); |
| 46 | 43 | |
| 44 | it.effect( | |
| 45 | "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", | |
| 46 | () => | |
| 47 | Effect.sync(() => { | |
| 48 | const decode = Schema.decodeUnknownSync(ServerSettings); | |
| 49 | ||
| 50 | const decoded = decode({ | |
| 51 | textGenerationModelSelection: { | |
| 52 | provider: "codex", | |
| 53 | model: "gpt-5.4-mini", | |
| 54 | options: { reasoningEffort: "low" }, | |
| 55 | }, | |
| 56 | }); | |
| 57 | ||
| 58 | assert.deepEqual(decoded.textGenerationModelSelection, { | |
| 59 | provider: "codex", | |
| 60 | model: "gpt-5.4-mini", | |
| 61 | options: [{ id: "reasoningEffort", value: "low" }], | |
| 62 | }); | |
| 63 | }), | |
| 64 | ); | |
| 65 | ||
| 47 | 66 | it.effect("deep merges nested settings updates without dropping siblings", () => |
| 48 | 67 | Effect.gen(function* () { |
| 49 | 68 | const serverSettings = yield* ServerSettingsService; |
| ... | ... | @@ -62,10 +81,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 62 | 81 | textGenerationModelSelection: { |
| 63 | 82 | provider: "codex", |
| 64 | 83 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 65 | options: { | |
| 66 | reasoningEffort: "high", | |
| 67 | fastMode: true, | |
| 68 | }, | |
| 84 | options: createModelSelection( | |
| 85 | "codex", | |
| 86 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 87 | [ | |
| 88 | { id: "reasoningEffort", value: "high" }, | |
| 89 | { id: "fastMode", value: true }, | |
| 90 | ], | |
| 91 | ).options!, | |
| 69 | 92 | }, |
| 70 | 93 | }); |
| 71 | 94 | |
| ... | ... | @@ -76,9 +99,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 76 | 99 | }, |
| 77 | 100 | }, |
| 78 | 101 | textGenerationModelSelection: { |
| 79 | options: { | |
| 80 | fastMode: false, | |
| 81 | }, | |
| 102 | options: [{ id: "fastMode", value: false }], | |
| 82 | 103 | }, |
| 83 | 104 | }); |
| 84 | 105 | |
| ... | ... | @@ -94,14 +115,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 94 | 115 | customModels: ["claude-custom"], |
| 95 | 116 | launchArgs: "", |
| 96 | 117 | }); |
| 97 | assert.deepEqual(next.textGenerationModelSelection, { | |
| 98 | provider: "codex", | |
| 99 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 100 | options: { | |
| 101 | reasoningEffort: "high", | |
| 102 | fastMode: false, | |
| 103 | }, | |
| 104 | }); | |
| 118 | assert.deepEqual( | |
| 119 | next.textGenerationModelSelection, | |
| 120 | createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ | |
| 121 | { id: "reasoningEffort", value: "high" }, | |
| 122 | { id: "fastMode", value: false }, | |
| 123 | ]), | |
| 124 | ); | |
| 105 | 125 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 106 | 126 | ); |
| 107 | 127 | |
| ... | ... | @@ -114,9 +134,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 114 | 134 | textGenerationModelSelection: { |
| 115 | 135 | provider: "claudeAgent", |
| 116 | 136 | model: "claude-sonnet-4-6", |
| 117 | options: { | |
| 118 | effort: "high", | |
| 119 | }, | |
| 137 | options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ | |
| 138 | { id: "effort", value: "high" }, | |
| 139 | ]).options!, | |
| 120 | 140 | }, |
| 121 | 141 | }); |
| 122 | 142 | |
| ... | ... | @@ -126,19 +146,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 126 | 146 | textGenerationModelSelection: { |
| 127 | 147 | provider: "codex", |
| 128 | 148 | model: "gpt-5.4", |
| 129 | options: { | |
| 130 | reasoningEffort: "high", | |
| 131 | }, | |
| 149 | options: createModelSelection("codex", "gpt-5.4", [ | |
| 150 | { id: "reasoningEffort", value: "high" }, | |
| 151 | ]).options!, | |
| 132 | 152 | }, |
| 133 | 153 | }); |
| 134 | 154 | |
| 135 | assert.deepEqual(next.textGenerationModelSelection, { | |
| 136 | provider: "codex", | |
| 137 | model: "gpt-5.4", | |
| 138 | options: { | |
| 139 | reasoningEffort: "high", | |
| 140 | }, | |
| 141 | }); | |
| 155 | assert.deepEqual( | |
| 156 | next.textGenerationModelSelection, | |
| 157 | createModelSelection("codex", "gpt-5.4", [{ id: "reasoningEffort", value: "high" }]), | |
| 158 | ); | |
| 142 | 159 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 143 | 160 | ); |
| 144 | 161 | |
| ... | ... | @@ -150,10 +167,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 150 | 167 | textGenerationModelSelection: { |
| 151 | 168 | provider: "codex", |
| 152 | 169 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| 153 | options: { | |
| 154 | reasoningEffort: "high", | |
| 155 | fastMode: true, | |
| 156 | }, | |
| 170 | options: createModelSelection( | |
| 171 | "codex", | |
| 172 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 173 | [ | |
| 174 | { id: "reasoningEffort", value: "high" }, | |
| 175 | { id: "fastMode", value: true }, | |
| 176 | ], | |
| 177 | ).options!, | |
| 157 | 178 | }, |
| 158 | 179 | }); |
| 159 | 180 |
apps/web/src/components/ChatView.browser.tsx+52-75| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | DEFAULT_SERVER_SETTINGS, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| 21 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | |
| 21 | 22 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 22 | 23 | import { HttpResponse, http, ws } from "msw"; |
| 23 | 24 | import { setupWorker } from "msw/browser"; |
| ... | ... | @@ -3777,14 +3778,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3777 | 3778 | it("snapshots sticky codex settings into a new draft thread", async () => { |
| 3778 | 3779 | useComposerDraftStore.setState({ |
| 3779 | 3780 | stickyModelSelectionByProvider: { |
| 3780 | codex: { | |
| 3781 | provider: "codex", | |
| 3782 | model: "gpt-5.3-codex", | |
| 3783 | options: { | |
| 3784 | reasoningEffort: "medium", | |
| 3785 | fastMode: true, | |
| 3786 | }, | |
| 3787 | }, | |
| 3781 | codex: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 3782 | { id: "reasoningEffort", value: "medium" }, | |
| 3783 | { id: "fastMode", value: true }, | |
| 3784 | ]), | |
| 3788 | 3785 | }, |
| 3789 | 3786 | stickyActiveProvider: "codex", |
| 3790 | 3787 | }); |
| ... | ... | @@ -3810,14 +3807,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3810 | 3807 | ); |
| 3811 | 3808 | const newDraftId = draftIdFromPath(newThreadPath); |
| 3812 | 3809 | |
| 3810 | // `toMatchObject` matches objects loosely (extras ignored) but compares | |
| 3811 | // arrays strictly, so wrap `options` in `arrayContaining` to keep the | |
| 3812 | // assertion focused on sticky `fastMode` carrying over without asserting | |
| 3813 | // on exactly which other options are preserved. | |
| 3813 | 3814 | expect(composerDraftFor(newDraftId)).toMatchObject({ |
| 3814 | 3815 | modelSelectionByProvider: { |
| 3815 | 3816 | codex: { |
| 3816 | 3817 | provider: "codex", |
| 3817 | 3818 | model: "gpt-5.3-codex", |
| 3818 | options: { | |
| 3819 | fastMode: true, | |
| 3820 | }, | |
| 3819 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | |
| 3821 | 3820 | }, |
| 3822 | 3821 | }, |
| 3823 | 3822 | activeProvider: "codex", |
| ... | ... | @@ -3830,14 +3829,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3830 | 3829 | it("hydrates the provider alongside a sticky claude model", async () => { |
| 3831 | 3830 | useComposerDraftStore.setState({ |
| 3832 | 3831 | stickyModelSelectionByProvider: { |
| 3833 | claudeAgent: { | |
| 3834 | provider: "claudeAgent", | |
| 3835 | model: "claude-opus-4-6", | |
| 3836 | options: { | |
| 3837 | effort: "max", | |
| 3838 | fastMode: true, | |
| 3839 | }, | |
| 3840 | }, | |
| 3832 | claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 3833 | { id: "effort", value: "max" }, | |
| 3834 | { id: "fastMode", value: true }, | |
| 3835 | ]), | |
| 3841 | 3836 | }, |
| 3842 | 3837 | stickyActiveProvider: "claudeAgent", |
| 3843 | 3838 | }); |
| ... | ... | @@ -3865,14 +3860,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3865 | 3860 | |
| 3866 | 3861 | expect(composerDraftFor(newDraftId)).toMatchObject({ |
| 3867 | 3862 | modelSelectionByProvider: { |
| 3868 | claudeAgent: { | |
| 3869 | provider: "claudeAgent", | |
| 3870 | model: "claude-opus-4-6", | |
| 3871 | options: { | |
| 3872 | effort: "max", | |
| 3873 | fastMode: true, | |
| 3874 | }, | |
| 3875 | }, | |
| 3863 | claudeAgent: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 3864 | { id: "effort", value: "max" }, | |
| 3865 | { id: "fastMode", value: true }, | |
| 3866 | ]), | |
| 3876 | 3867 | }, |
| 3877 | 3868 | activeProvider: "claudeAgent", |
| 3878 | 3869 | }); |
| ... | ... | @@ -3912,14 +3903,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3912 | 3903 | it("prefers draft state over sticky composer settings and defaults", async () => { |
| 3913 | 3904 | useComposerDraftStore.setState({ |
| 3914 | 3905 | stickyModelSelectionByProvider: { |
| 3915 | codex: { | |
| 3916 | provider: "codex", | |
| 3917 | model: "gpt-5.3-codex", | |
| 3918 | options: { | |
| 3919 | reasoningEffort: "medium", | |
| 3920 | fastMode: true, | |
| 3921 | }, | |
| 3922 | }, | |
| 3906 | codex: createModelSelection("codex", "gpt-5.3-codex", [ | |
| 3907 | { id: "reasoningEffort", value: "medium" }, | |
| 3908 | { id: "fastMode", value: true }, | |
| 3909 | ]), | |
| 3923 | 3910 | }, |
| 3924 | 3911 | stickyActiveProvider: "codex", |
| 3925 | 3912 | }); |
| ... | ... | @@ -3945,27 +3932,27 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3945 | 3932 | ); |
| 3946 | 3933 | const draftId = draftIdFromPath(threadPath); |
| 3947 | 3934 | |
| 3935 | // See the note on the sibling sticky-codex test: arrays match strictly | |
| 3936 | // under `toMatchObject`, so use `arrayContaining` to keep the assertion | |
| 3937 | // scoped to the sticky trait (`fastMode`) that must carry over. | |
| 3948 | 3938 | expect(composerDraftFor(draftId)).toMatchObject({ |
| 3949 | 3939 | modelSelectionByProvider: { |
| 3950 | 3940 | codex: { |
| 3951 | 3941 | provider: "codex", |
| 3952 | 3942 | model: "gpt-5.3-codex", |
| 3953 | options: { | |
| 3954 | fastMode: true, | |
| 3955 | }, | |
| 3943 | options: expect.arrayContaining([{ id: "fastMode", value: true }]), | |
| 3956 | 3944 | }, |
| 3957 | 3945 | }, |
| 3958 | 3946 | activeProvider: "codex", |
| 3959 | 3947 | }); |
| 3960 | 3948 | |
| 3961 | useComposerDraftStore.getState().setModelSelection(draftId, { | |
| 3962 | provider: "codex", | |
| 3963 | model: "gpt-5.4", | |
| 3964 | options: { | |
| 3965 | reasoningEffort: "low", | |
| 3966 | fastMode: true, | |
| 3967 | }, | |
| 3968 | }); | |
| 3949 | useComposerDraftStore.getState().setModelSelection( | |
| 3950 | draftId, | |
| 3951 | createModelSelection("codex", "gpt-5.4", [ | |
| 3952 | { id: "reasoningEffort", value: "low" }, | |
| 3953 | { id: "fastMode", value: true }, | |
| 3954 | ]), | |
| 3955 | ); | |
| 3969 | 3956 | |
| 3970 | 3957 | await newThreadButton.click(); |
| 3971 | 3958 | |
| ... | ... | @@ -3976,14 +3963,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3976 | 3963 | ); |
| 3977 | 3964 | expect(composerDraftFor(draftId)).toMatchObject({ |
| 3978 | 3965 | modelSelectionByProvider: { |
| 3979 | codex: { | |
| 3980 | provider: "codex", | |
| 3981 | model: "gpt-5.4", | |
| 3982 | options: { | |
| 3983 | reasoningEffort: "low", | |
| 3984 | fastMode: true, | |
| 3985 | }, | |
| 3986 | }, | |
| 3966 | codex: createModelSelection("codex", "gpt-5.4", [ | |
| 3967 | { id: "reasoningEffort", value: "low" }, | |
| 3968 | { id: "fastMode", value: true }, | |
| 3969 | ]), | |
| 3987 | 3970 | }, |
| 3988 | 3971 | activeProvider: "codex", |
| 3989 | 3972 | }); |
| ... | ... | @@ -5670,37 +5653,31 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5670 | 5653 | slug: "gpt-5.1-codex-max", |
| 5671 | 5654 | name: "GPT-5.1 Codex Max", |
| 5672 | 5655 | isCustom: false, |
| 5673 | capabilities: { | |
| 5674 | supportsFastMode: true, | |
| 5675 | supportsThinkingToggle: false, | |
| 5676 | reasoningEffortLevels: [], | |
| 5677 | promptInjectedEffortLevels: [], | |
| 5678 | contextWindowOptions: [], | |
| 5679 | }, | |
| 5656 | capabilities: createModelCapabilities({ | |
| 5657 | optionDescriptors: [ | |
| 5658 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | |
| 5659 | ], | |
| 5660 | }), | |
| 5680 | 5661 | }, |
| 5681 | 5662 | { |
| 5682 | 5663 | slug: "gpt-5.3-codex", |
| 5683 | 5664 | name: "GPT-5.3 Codex", |
| 5684 | 5665 | isCustom: false, |
| 5685 | capabilities: { | |
| 5686 | supportsFastMode: true, | |
| 5687 | supportsThinkingToggle: false, | |
| 5688 | reasoningEffortLevels: [], | |
| 5689 | promptInjectedEffortLevels: [], | |
| 5690 | contextWindowOptions: [], | |
| 5691 | }, | |
| 5666 | capabilities: createModelCapabilities({ | |
| 5667 | optionDescriptors: [ | |
| 5668 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | |
| 5669 | ], | |
| 5670 | }), | |
| 5692 | 5671 | }, |
| 5693 | 5672 | { |
| 5694 | 5673 | slug: "gpt-5.4", |
| 5695 | 5674 | name: "GPT-5.4", |
| 5696 | 5675 | isCustom: false, |
| 5697 | capabilities: { | |
| 5698 | supportsFastMode: true, | |
| 5699 | supportsThinkingToggle: false, | |
| 5700 | reasoningEffortLevels: [], | |
| 5701 | promptInjectedEffortLevels: [], | |
| 5702 | contextWindowOptions: [], | |
| 5703 | }, | |
| 5676 | capabilities: createModelCapabilities({ | |
| 5677 | optionDescriptors: [ | |
| 5678 | { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, | |
| 5679 | ], | |
| 5680 | }), | |
| 5704 | 5681 | }, |
| 5705 | 5682 | ], |
| 5706 | 5683 | }, |
apps/web/src/components/ChatView.tsx+7-6| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ApprovalRequestId, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | type ClaudeAgentEffort, | |
| 5 | 4 | type EnvironmentId, |
| 6 | 5 | type MessageId, |
| 7 | 6 | type ModelSelection, |
| ... | ... | @@ -26,7 +25,11 @@ import { |
| 26 | 25 | scopeProjectRef, |
| 27 | 26 | scopeThreadRef, |
| 28 | 27 | } from "@t3tools/client-runtime"; |
| 29 | import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; | |
| 28 | import { | |
| 29 | applyClaudePromptEffortPrefix, | |
| 30 | createModelSelection, | |
| 31 | resolvePromptInjectedEffort, | |
| 32 | } from "@t3tools/shared/model"; | |
| 30 | 33 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 31 | 34 | import { truncate } from "@t3tools/shared/String"; |
| 32 | 35 | import { Debouncer } from "@tanstack/react-pacer"; |
| ... | ... | @@ -306,10 +309,8 @@ function formatOutgoingPrompt(params: { |
| 306 | 309 | text: string; |
| 307 | 310 | }): string { |
| 308 | 311 | const caps = getProviderModelCapabilities(params.models, params.model, params.provider); |
| 309 | if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { | |
| 310 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); | |
| 311 | } | |
| 312 | return params.text; | |
| 312 | const promptEffort = resolvePromptInjectedEffort(caps, params.effort); | |
| 313 | return applyClaudePromptEffortPrefix(params.text, promptEffort); | |
| 313 | 314 | } |
| 314 | 315 | const SCRIPT_TERMINAL_COLS = 120; |
| 315 | 316 | const SCRIPT_TERMINAL_ROWS = 30; |
apps/web/src/components/chat/ChatComposer.tsx+14-6| ... | ... | @@ -71,11 +71,10 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; |
| 71 | 71 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 72 | 72 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; |
| 73 | 73 | import { |
| 74 | getComposerProviderControls, | |
| 75 | 74 | getComposerProviderState, |
| 76 | 75 | renderProviderTraitsMenuContent, |
| 77 | 76 | renderProviderTraitsPicker, |
| 78 | } from "./composerProviderRegistry"; | |
| 77 | } from "./composerProviderState"; | |
| 79 | 78 | import { ContextWindowMeter } from "./ContextWindowMeter"; |
| 80 | 79 | import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 81 | 80 | import { basenameOfPath } from "../../vscode-icons"; |
| ... | ... | @@ -96,7 +95,11 @@ import { |
| 96 | 95 | XIcon, |
| 97 | 96 | } from "lucide-react"; |
| 98 | 97 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 99 | import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; | |
| 98 | import { | |
| 99 | getProviderInteractionModeToggle, | |
| 100 | getProviderModels, | |
| 101 | resolveSelectableProvider, | |
| 102 | } from "../../providerModels"; | |
| 100 | 103 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 101 | 104 | import type { SessionPhase, Thread } from "../../types"; |
| 102 | 105 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| ... | ... | @@ -595,7 +598,7 @@ export const ChatComposer = memo( |
| 595 | 598 | model: selectedModel, |
| 596 | 599 | models: selectedProviderModels, |
| 597 | 600 | prompt, |
| 598 | modelOptions: composerModelOptions, | |
| 601 | modelOptions: composerModelOptions?.[selectedProvider], | |
| 599 | 602 | }), |
| 600 | 603 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], |
| 601 | 604 | ); |
| ... | ... | @@ -603,8 +606,13 @@ export const ChatComposer = memo( |
| 603 | 606 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 604 | 607 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| 605 | 608 | const composerProviderControls = useMemo( |
| 606 | () => getComposerProviderControls(selectedProvider), | |
| 607 | [selectedProvider], | |
| 609 | () => ({ | |
| 610 | showInteractionModeToggle: getProviderInteractionModeToggle( | |
| 611 | providerStatuses, | |
| 612 | selectedProvider, | |
| 613 | ), | |
| 614 | }), | |
| 615 | [providerStatuses, selectedProvider], | |
| 608 | 616 | ); |
| 609 | 617 | const selectedModelSelection = useMemo<ModelSelection>( |
| 610 | 618 | () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+99-96| ... | ... | @@ -10,6 +10,7 @@ import "../../index.css"; |
| 10 | 10 | import { page } from "vitest/browser"; |
| 11 | 11 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 12 | 12 | import { render } from "vitest-browser-react"; |
| 13 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | |
| 13 | 14 | |
| 14 | 15 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 15 | 16 | import { TraitsMenuContent } from "./TraitsPicker"; |
| ... | ... | @@ -17,6 +18,34 @@ import { useComposerDraftStore } from "../../composerDraftStore"; |
| 17 | 18 | |
| 18 | 19 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 19 | 20 | |
| 21 | function selectDescriptor( | |
| 22 | id: string, | |
| 23 | label: string, | |
| 24 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 25 | promptInjectedValues?: ReadonlyArray<string>, | |
| 26 | ) { | |
| 27 | return { | |
| 28 | id, | |
| 29 | label, | |
| 30 | type: "select" as const, | |
| 31 | options: [...options], | |
| 32 | ...(options.find((option) => option.isDefault)?.id | |
| 33 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 34 | : {}), | |
| 35 | ...(promptInjectedValues && promptInjectedValues.length > 0 | |
| 36 | ? { promptInjectedValues: [...promptInjectedValues] } | |
| 37 | : {}), | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function booleanDescriptor(id: string, label: string) { | |
| 42 | return { | |
| 43 | id, | |
| 44 | label, | |
| 45 | type: "boolean" as const, | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 20 | 49 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { |
| 21 | 50 | const threadId = ThreadId.make("thread-compact-menu"); |
| 22 | 51 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); |
| ... | ... | @@ -33,11 +62,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 33 | 62 | persistedAttachments: [], |
| 34 | 63 | terminalContexts: [], |
| 35 | 64 | modelSelectionByProvider: { |
| 36 | [provider]: { | |
| 37 | provider, | |
| 38 | model, | |
| 39 | ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), | |
| 40 | }, | |
| 65 | [provider]: createModelSelection(provider, model, props?.modelSelection?.options), | |
| 41 | 66 | }, |
| 42 | 67 | activeProvider: provider, |
| 43 | 68 | runtimeMode: null, |
| ... | ... | @@ -51,74 +76,58 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 51 | 76 | document.body.append(host); |
| 52 | 77 | const onPromptChange = vi.fn(); |
| 53 | 78 | const providerOptions = props?.modelSelection?.options; |
| 54 | const models = | |
| 55 | provider === "claudeAgent" | |
| 56 | ? [ | |
| 57 | { | |
| 58 | slug: "claude-opus-4-6", | |
| 59 | name: "Claude Opus 4.6", | |
| 60 | isCustom: false, | |
| 61 | capabilities: { | |
| 62 | reasoningEffortLevels: [ | |
| 63 | { value: "low", label: "Low" }, | |
| 64 | { value: "medium", label: "Medium" }, | |
| 65 | { value: "high", label: "High", isDefault: true }, | |
| 66 | { value: "max", label: "Max" }, | |
| 67 | { value: "ultrathink", label: "Ultrathink" }, | |
| 68 | ], | |
| 69 | supportsFastMode: true, | |
| 70 | supportsThinkingToggle: false, | |
| 71 | contextWindowOptions: [], | |
| 72 | promptInjectedEffortLevels: ["ultrathink"], | |
| 73 | }, | |
| 74 | }, | |
| 75 | { | |
| 76 | slug: "claude-haiku-4-5", | |
| 77 | name: "Claude Haiku 4.5", | |
| 78 | isCustom: false, | |
| 79 | capabilities: { | |
| 80 | reasoningEffortLevels: [], | |
| 81 | supportsFastMode: false, | |
| 82 | supportsThinkingToggle: true, | |
| 83 | contextWindowOptions: [], | |
| 84 | promptInjectedEffortLevels: [], | |
| 85 | }, | |
| 86 | }, | |
| 87 | { | |
| 88 | slug: "claude-sonnet-4-6", | |
| 89 | name: "Claude Sonnet 4.6", | |
| 90 | isCustom: false, | |
| 91 | capabilities: { | |
| 92 | reasoningEffortLevels: [ | |
| 93 | { value: "low", label: "Low" }, | |
| 94 | { value: "medium", label: "Medium" }, | |
| 95 | { value: "high", label: "High", isDefault: true }, | |
| 96 | { value: "ultrathink", label: "Ultrathink" }, | |
| 97 | ], | |
| 98 | supportsFastMode: false, | |
| 99 | supportsThinkingToggle: false, | |
| 100 | contextWindowOptions: [], | |
| 101 | promptInjectedEffortLevels: ["ultrathink"], | |
| 102 | }, | |
| 103 | }, | |
| 104 | ] | |
| 105 | : [ | |
| 106 | { | |
| 107 | slug: "gpt-5.4", | |
| 108 | name: "GPT-5.4", | |
| 109 | isCustom: false, | |
| 110 | capabilities: { | |
| 111 | reasoningEffortLevels: [ | |
| 112 | { value: "xhigh", label: "Extra High" }, | |
| 113 | { value: "high", label: "High", isDefault: true }, | |
| 114 | ], | |
| 115 | supportsFastMode: true, | |
| 116 | supportsThinkingToggle: false, | |
| 117 | contextWindowOptions: [], | |
| 118 | promptInjectedEffortLevels: [], | |
| 119 | }, | |
| 120 | }, | |
| 121 | ]; | |
| 79 | const models = [ | |
| 80 | { | |
| 81 | slug: "claude-opus-4-6", | |
| 82 | name: "Claude Opus 4.6", | |
| 83 | isCustom: false, | |
| 84 | capabilities: createModelCapabilities({ | |
| 85 | optionDescriptors: [ | |
| 86 | selectDescriptor( | |
| 87 | "effort", | |
| 88 | "Reasoning", | |
| 89 | [ | |
| 90 | { id: "low", label: "Low" }, | |
| 91 | { id: "medium", label: "Medium" }, | |
| 92 | { id: "high", label: "High", isDefault: true }, | |
| 93 | { id: "max", label: "Max" }, | |
| 94 | { id: "ultrathink", label: "Ultrathink" }, | |
| 95 | ], | |
| 96 | ["ultrathink"], | |
| 97 | ), | |
| 98 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 99 | ], | |
| 100 | }), | |
| 101 | }, | |
| 102 | { | |
| 103 | slug: "claude-haiku-4-5", | |
| 104 | name: "Claude Haiku 4.5", | |
| 105 | isCustom: false, | |
| 106 | capabilities: createModelCapabilities({ | |
| 107 | optionDescriptors: [booleanDescriptor("thinking", "Thinking")], | |
| 108 | }), | |
| 109 | }, | |
| 110 | { | |
| 111 | slug: "claude-sonnet-4-6", | |
| 112 | name: "Claude Sonnet 4.6", | |
| 113 | isCustom: false, | |
| 114 | capabilities: createModelCapabilities({ | |
| 115 | optionDescriptors: [ | |
| 116 | selectDescriptor( | |
| 117 | "effort", | |
| 118 | "Reasoning", | |
| 119 | [ | |
| 120 | { id: "low", label: "Low" }, | |
| 121 | { id: "medium", label: "Medium" }, | |
| 122 | { id: "high", label: "High", isDefault: true }, | |
| 123 | { id: "ultrathink", label: "Ultrathink" }, | |
| 124 | ], | |
| 125 | ["ultrathink"], | |
| 126 | ), | |
| 127 | ], | |
| 128 | }), | |
| 129 | }, | |
| 130 | ]; | |
| 122 | 131 | const screen = await render( |
| 123 | 132 | <CompactComposerControlsMenu |
| 124 | 133 | activePlan={false} |
| ... | ... | @@ -169,7 +178,7 @@ describe("CompactComposerControlsMenu", () => { |
| 169 | 178 | |
| 170 | 179 | it("shows fast mode controls for Opus", async () => { |
| 171 | 180 | await using _ = await mountMenu({ |
| 172 | modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, | |
| 181 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6"), | |
| 173 | 182 | }); |
| 174 | 183 | |
| 175 | 184 | await page.getByLabelText("More composer controls").click(); |
| ... | ... | @@ -177,14 +186,14 @@ describe("CompactComposerControlsMenu", () => { |
| 177 | 186 | await vi.waitFor(() => { |
| 178 | 187 | const text = document.body.textContent ?? ""; |
| 179 | 188 | expect(text).toContain("Fast Mode"); |
| 180 | expect(text).toContain("off"); | |
| 181 | expect(text).toContain("on"); | |
| 189 | expect(text).toContain("On"); | |
| 190 | expect(text).toContain("Off"); | |
| 182 | 191 | }); |
| 183 | 192 | }); |
| 184 | 193 | |
| 185 | 194 | it("hides fast mode controls for non-Opus Claude models", async () => { |
| 186 | 195 | await using _ = await mountMenu({ |
| 187 | modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | |
| 196 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), | |
| 188 | 197 | }); |
| 189 | 198 | |
| 190 | 199 | await page.getByLabelText("More composer controls").click(); |
| ... | ... | @@ -196,7 +205,7 @@ describe("CompactComposerControlsMenu", () => { |
| 196 | 205 | |
| 197 | 206 | it("shows only the provided effort options", async () => { |
| 198 | 207 | await using _ = await mountMenu({ |
| 199 | modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | |
| 208 | modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"), | |
| 200 | 209 | }); |
| 201 | 210 | |
| 202 | 211 | await page.getByLabelText("More composer controls").click(); |
| ... | ... | @@ -213,11 +222,9 @@ describe("CompactComposerControlsMenu", () => { |
| 213 | 222 | |
| 214 | 223 | it("shows a Claude thinking on/off section for Haiku", async () => { |
| 215 | 224 | await using _ = await mountMenu({ |
| 216 | modelSelection: { | |
| 217 | provider: "claudeAgent", | |
| 218 | model: "claude-haiku-4-5", | |
| 219 | options: { thinking: true }, | |
| 220 | }, | |
| 225 | modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ | |
| 226 | { id: "thinking", value: true }, | |
| 227 | ]), | |
| 221 | 228 | }); |
| 222 | 229 | |
| 223 | 230 | await page.getByLabelText("More composer controls").click(); |
| ... | ... | @@ -225,18 +232,16 @@ describe("CompactComposerControlsMenu", () => { |
| 225 | 232 | await vi.waitFor(() => { |
| 226 | 233 | const text = document.body.textContent ?? ""; |
| 227 | 234 | expect(text).toContain("Thinking"); |
| 228 | expect(text).toContain("On (default)"); | |
| 235 | expect(text).toContain("On"); | |
| 229 | 236 | expect(text).toContain("Off"); |
| 230 | 237 | }); |
| 231 | 238 | }); |
| 232 | 239 | |
| 233 | 240 | it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { |
| 234 | 241 | await using _ = await mountMenu({ |
| 235 | modelSelection: { | |
| 236 | provider: "claudeAgent", | |
| 237 | model: "claude-opus-4-6", | |
| 238 | options: { effort: "high" }, | |
| 239 | }, | |
| 242 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 243 | { id: "effort", value: "high" }, | |
| 244 | ]), | |
| 240 | 245 | prompt: "Ultrathink:\nInvestigate this", |
| 241 | 246 | }); |
| 242 | 247 | |
| ... | ... | @@ -244,18 +249,16 @@ describe("CompactComposerControlsMenu", () => { |
| 244 | 249 | |
| 245 | 250 | await vi.waitFor(() => { |
| 246 | 251 | const text = document.body.textContent ?? ""; |
| 247 | expect(text).toContain("Effort"); | |
| 252 | expect(text).toContain("Reasoning"); | |
| 248 | 253 | expect(text).not.toContain("ultrathink"); |
| 249 | 254 | }); |
| 250 | 255 | }); |
| 251 | 256 | |
| 252 | 257 | it("warns when ultrathink appears in prompt body text", async () => { |
| 253 | 258 | await using _ = await mountMenu({ |
| 254 | modelSelection: { | |
| 255 | provider: "claudeAgent", | |
| 256 | model: "claude-opus-4-6", | |
| 257 | options: { effort: "high" }, | |
| 258 | }, | |
| 259 | modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ | |
| 260 | { id: "effort", value: "high" }, | |
| 261 | ]), | |
| 259 | 262 | prompt: "Ultrathink:\nplease ultrathink about this problem", |
| 260 | 263 | }); |
| 261 | 264 | |
| ... | ... | @@ -264,7 +267,7 @@ describe("CompactComposerControlsMenu", () => { |
| 264 | 267 | await vi.waitFor(() => { |
| 265 | 268 | const text = document.body.textContent ?? ""; |
| 266 | 269 | expect(text).toContain( |
| 267 | 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', | |
| 270 | 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', | |
| 268 | 271 | ); |
| 269 | 272 | }); |
| 270 | 273 | }); |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+151-107| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 3 | 4 | import { page, userEvent } from "vitest/browser"; |
| 4 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | 6 | import { render } from "vitest-browser-react"; |
| ... | ... | @@ -66,17 +67,34 @@ vi.mock("../../environments/runtime", () => { |
| 66 | 67 | }; |
| 67 | 68 | }); |
| 68 | 69 | |
| 69 | function effort(value: string, isDefault = false) { | |
| 70 | function selectDescriptor( | |
| 71 | id: string, | |
| 72 | label: string, | |
| 73 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 74 | ) { | |
| 70 | 75 | return { |
| 71 | value, | |
| 72 | label: value, | |
| 73 | ...(isDefault ? { isDefault: true } : {}), | |
| 76 | id, | |
| 77 | label, | |
| 78 | type: "select" as const, | |
| 79 | options: [...options], | |
| 80 | ...(options.find((option) => option.isDefault)?.id | |
| 81 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 82 | : {}), | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | function booleanDescriptor(id: string, label: string) { | |
| 87 | return { | |
| 88 | id, | |
| 89 | label, | |
| 90 | type: "boolean" as const, | |
| 74 | 91 | }; |
| 75 | 92 | } |
| 76 | 93 | |
| 77 | 94 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 78 | 95 | { |
| 79 | 96 | provider: "codex", |
| 97 | displayName: "Codex", | |
| 80 | 98 | enabled: true, |
| 81 | 99 | installed: true, |
| 82 | 100 | version: "0.116.0", |
| ... | ... | @@ -90,30 +108,37 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 90 | 108 | slug: "gpt-5-codex", |
| 91 | 109 | name: "GPT-5 Codex", |
| 92 | 110 | isCustom: false, |
| 93 | capabilities: { | |
| 94 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 95 | supportsFastMode: true, | |
| 96 | supportsThinkingToggle: false, | |
| 97 | contextWindowOptions: [], | |
| 98 | promptInjectedEffortLevels: [], | |
| 99 | }, | |
| 111 | capabilities: createModelCapabilities({ | |
| 112 | optionDescriptors: [ | |
| 113 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 114 | { id: "low", label: "low" }, | |
| 115 | { id: "medium", label: "medium", isDefault: true }, | |
| 116 | { id: "high", label: "high" }, | |
| 117 | ]), | |
| 118 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 119 | ], | |
| 120 | }), | |
| 100 | 121 | }, |
| 101 | 122 | { |
| 102 | 123 | slug: "gpt-5.3-codex", |
| 103 | 124 | name: "GPT-5.3 Codex", |
| 104 | 125 | isCustom: false, |
| 105 | capabilities: { | |
| 106 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 107 | supportsFastMode: true, | |
| 108 | supportsThinkingToggle: false, | |
| 109 | contextWindowOptions: [], | |
| 110 | promptInjectedEffortLevels: [], | |
| 111 | }, | |
| 126 | capabilities: createModelCapabilities({ | |
| 127 | optionDescriptors: [ | |
| 128 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 129 | { id: "low", label: "low" }, | |
| 130 | { id: "medium", label: "medium", isDefault: true }, | |
| 131 | { id: "high", label: "high" }, | |
| 132 | ]), | |
| 133 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 134 | ], | |
| 135 | }), | |
| 112 | 136 | }, |
| 113 | 137 | ], |
| 114 | 138 | }, |
| 115 | 139 | { |
| 116 | 140 | provider: "claudeAgent", |
| 141 | displayName: "Claude", | |
| 117 | 142 | enabled: true, |
| 118 | 143 | installed: true, |
| 119 | 144 | version: "1.0.0", |
| ... | ... | @@ -127,47 +152,48 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 127 | 152 | slug: "claude-opus-4-6", |
| 128 | 153 | name: "Claude Opus 4.6", |
| 129 | 154 | isCustom: false, |
| 130 | capabilities: { | |
| 131 | reasoningEffortLevels: [ | |
| 132 | effort("low"), | |
| 133 | effort("medium", true), | |
| 134 | effort("high"), | |
| 135 | effort("max"), | |
| 155 | capabilities: createModelCapabilities({ | |
| 156 | optionDescriptors: [ | |
| 157 | selectDescriptor("effort", "Reasoning", [ | |
| 158 | { id: "low", label: "low" }, | |
| 159 | { id: "medium", label: "medium", isDefault: true }, | |
| 160 | { id: "high", label: "high" }, | |
| 161 | { id: "max", label: "max" }, | |
| 162 | ]), | |
| 163 | booleanDescriptor("thinking", "Thinking"), | |
| 136 | 164 | ], |
| 137 | supportsFastMode: false, | |
| 138 | supportsThinkingToggle: true, | |
| 139 | contextWindowOptions: [], | |
| 140 | promptInjectedEffortLevels: [], | |
| 141 | }, | |
| 165 | }), | |
| 142 | 166 | }, |
| 143 | 167 | { |
| 144 | 168 | slug: "claude-sonnet-4-6", |
| 145 | 169 | name: "Claude Sonnet 4.6", |
| 146 | 170 | isCustom: false, |
| 147 | capabilities: { | |
| 148 | reasoningEffortLevels: [ | |
| 149 | effort("low"), | |
| 150 | effort("medium", true), | |
| 151 | effort("high"), | |
| 152 | effort("max"), | |
| 171 | capabilities: createModelCapabilities({ | |
| 172 | optionDescriptors: [ | |
| 173 | selectDescriptor("effort", "Reasoning", [ | |
| 174 | { id: "low", label: "low" }, | |
| 175 | { id: "medium", label: "medium", isDefault: true }, | |
| 176 | { id: "high", label: "high" }, | |
| 177 | { id: "max", label: "max" }, | |
| 178 | ]), | |
| 179 | booleanDescriptor("thinking", "Thinking"), | |
| 153 | 180 | ], |
| 154 | supportsFastMode: false, | |
| 155 | supportsThinkingToggle: true, | |
| 156 | contextWindowOptions: [], | |
| 157 | promptInjectedEffortLevels: [], | |
| 158 | }, | |
| 181 | }), | |
| 159 | 182 | }, |
| 160 | 183 | { |
| 161 | 184 | slug: "claude-haiku-4-5", |
| 162 | 185 | name: "Claude Haiku 4.5", |
| 163 | 186 | isCustom: false, |
| 164 | capabilities: { | |
| 165 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 166 | supportsFastMode: false, | |
| 167 | supportsThinkingToggle: true, | |
| 168 | contextWindowOptions: [], | |
| 169 | promptInjectedEffortLevels: [], | |
| 170 | }, | |
| 187 | capabilities: createModelCapabilities({ | |
| 188 | optionDescriptors: [ | |
| 189 | selectDescriptor("effort", "Reasoning", [ | |
| 190 | { id: "low", label: "low" }, | |
| 191 | { id: "medium", label: "medium", isDefault: true }, | |
| 192 | { id: "high", label: "high" }, | |
| 193 | ]), | |
| 194 | booleanDescriptor("thinking", "Thinking"), | |
| 195 | ], | |
| 196 | }), | |
| 171 | 197 | }, |
| 172 | 198 | ], |
| 173 | 199 | }, |
| ... | ... | @@ -176,6 +202,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 176 | 202 | function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { |
| 177 | 203 | return { |
| 178 | 204 | provider: "codex", |
| 205 | displayName: "Codex", | |
| 179 | 206 | enabled: true, |
| 180 | 207 | installed: true, |
| 181 | 208 | version: "0.116.0", |
| ... | ... | @@ -464,13 +491,15 @@ describe("ProviderModelPicker", () => { |
| 464 | 491 | subProvider: "GitHub Copilot", |
| 465 | 492 | shortName: "Opus 4.5", |
| 466 | 493 | isCustom: false, |
| 467 | capabilities: { | |
| 468 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 469 | supportsFastMode: false, | |
| 470 | supportsThinkingToggle: false, | |
| 471 | contextWindowOptions: [], | |
| 472 | promptInjectedEffortLevels: [], | |
| 473 | }, | |
| 494 | capabilities: createModelCapabilities({ | |
| 495 | optionDescriptors: [ | |
| 496 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 497 | { id: "low", label: "low" }, | |
| 498 | { id: "medium", label: "medium", isDefault: true }, | |
| 499 | { id: "high", label: "high" }, | |
| 500 | ]), | |
| 501 | ], | |
| 502 | }), | |
| 474 | 503 | }, |
| 475 | 504 | ]), |
| 476 | 505 | ]; |
| ... | ... | @@ -658,13 +687,16 @@ describe("ProviderModelPicker", () => { |
| 658 | 687 | slug: "gpt-5-codex", |
| 659 | 688 | name: "GPT-5 Codex", |
| 660 | 689 | isCustom: false, |
| 661 | capabilities: { | |
| 662 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 663 | supportsFastMode: true, | |
| 664 | supportsThinkingToggle: false, | |
| 665 | contextWindowOptions: [], | |
| 666 | promptInjectedEffortLevels: [], | |
| 667 | }, | |
| 690 | capabilities: createModelCapabilities({ | |
| 691 | optionDescriptors: [ | |
| 692 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 693 | { id: "low", label: "low" }, | |
| 694 | { id: "medium", label: "medium", isDefault: true }, | |
| 695 | { id: "high", label: "high" }, | |
| 696 | ]), | |
| 697 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 698 | ], | |
| 699 | }), | |
| 668 | 700 | }, |
| 669 | 701 | ]), |
| 670 | 702 | buildOpenCodeProvider([ |
| ... | ... | @@ -673,13 +705,15 @@ describe("ProviderModelPicker", () => { |
| 673 | 705 | name: "Claude Opus 4.7", |
| 674 | 706 | subProvider: "GitHub Copilot", |
| 675 | 707 | isCustom: false, |
| 676 | capabilities: { | |
| 677 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 678 | supportsFastMode: false, | |
| 679 | supportsThinkingToggle: false, | |
| 680 | contextWindowOptions: [], | |
| 681 | promptInjectedEffortLevels: [], | |
| 682 | }, | |
| 708 | capabilities: createModelCapabilities({ | |
| 709 | optionDescriptors: [ | |
| 710 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 711 | { id: "low", label: "low" }, | |
| 712 | { id: "medium", label: "medium", isDefault: true }, | |
| 713 | { id: "high", label: "high" }, | |
| 714 | ]), | |
| 715 | ], | |
| 716 | }), | |
| 683 | 717 | }, |
| 684 | 718 | ]), |
| 685 | 719 | ]; |
| ... | ... | @@ -712,13 +746,15 @@ describe("ProviderModelPicker", () => { |
| 712 | 746 | name: "Claude Opus 4.7", |
| 713 | 747 | subProvider: "GitHub Copilot", |
| 714 | 748 | isCustom: false, |
| 715 | capabilities: { | |
| 716 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 717 | supportsFastMode: false, | |
| 718 | supportsThinkingToggle: false, | |
| 719 | contextWindowOptions: [], | |
| 720 | promptInjectedEffortLevels: [], | |
| 721 | }, | |
| 749 | capabilities: createModelCapabilities({ | |
| 750 | optionDescriptors: [ | |
| 751 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 752 | { id: "low", label: "low" }, | |
| 753 | { id: "medium", label: "medium", isDefault: true }, | |
| 754 | { id: "high", label: "high" }, | |
| 755 | ]), | |
| 756 | ], | |
| 757 | }), | |
| 722 | 758 | }, |
| 723 | 759 | ]), |
| 724 | 760 | { |
| ... | ... | @@ -728,18 +764,17 @@ describe("ProviderModelPicker", () => { |
| 728 | 764 | slug: "claude-opus-4-6", |
| 729 | 765 | name: "Claude Opus 4.6", |
| 730 | 766 | isCustom: false, |
| 731 | capabilities: { | |
| 732 | reasoningEffortLevels: [ | |
| 733 | effort("low"), | |
| 734 | effort("medium", true), | |
| 735 | effort("high"), | |
| 736 | effort("max"), | |
| 767 | capabilities: createModelCapabilities({ | |
| 768 | optionDescriptors: [ | |
| 769 | selectDescriptor("effort", "Reasoning", [ | |
| 770 | { id: "low", label: "low" }, | |
| 771 | { id: "medium", label: "medium", isDefault: true }, | |
| 772 | { id: "high", label: "high" }, | |
| 773 | { id: "max", label: "max" }, | |
| 774 | ]), | |
| 775 | booleanDescriptor("thinking", "Thinking"), | |
| 737 | 776 | ], |
| 738 | supportsFastMode: false, | |
| 739 | supportsThinkingToggle: true, | |
| 740 | contextWindowOptions: [], | |
| 741 | promptInjectedEffortLevels: [], | |
| 742 | }, | |
| 777 | }), | |
| 743 | 778 | }, |
| 744 | 779 | ], |
| 745 | 780 | }, |
| ... | ... | @@ -909,13 +944,16 @@ describe("ProviderModelPicker", () => { |
| 909 | 944 | slug: "gpt-5.3-codex", |
| 910 | 945 | name: "GPT-5.3 Codex", |
| 911 | 946 | isCustom: false, |
| 912 | capabilities: { | |
| 913 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 914 | supportsFastMode: true, | |
| 915 | supportsThinkingToggle: false, | |
| 916 | contextWindowOptions: [], | |
| 917 | promptInjectedEffortLevels: [], | |
| 918 | }, | |
| 947 | capabilities: createModelCapabilities({ | |
| 948 | optionDescriptors: [ | |
| 949 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 950 | { id: "low", label: "low" }, | |
| 951 | { id: "medium", label: "medium", isDefault: true }, | |
| 952 | { id: "high", label: "high" }, | |
| 953 | ]), | |
| 954 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 955 | ], | |
| 956 | }), | |
| 919 | 957 | }, |
| 920 | 958 | ]), |
| 921 | 959 | TEST_PROVIDERS[1]!, |
| ... | ... | @@ -926,25 +964,31 @@ describe("ProviderModelPicker", () => { |
| 926 | 964 | slug: "gpt-5.3-codex", |
| 927 | 965 | name: "GPT-5.3 Codex", |
| 928 | 966 | isCustom: false, |
| 929 | capabilities: { | |
| 930 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 931 | supportsFastMode: true, | |
| 932 | supportsThinkingToggle: false, | |
| 933 | contextWindowOptions: [], | |
| 934 | promptInjectedEffortLevels: [], | |
| 935 | }, | |
| 967 | capabilities: createModelCapabilities({ | |
| 968 | optionDescriptors: [ | |
| 969 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 970 | { id: "low", label: "low" }, | |
| 971 | { id: "medium", label: "medium", isDefault: true }, | |
| 972 | { id: "high", label: "high" }, | |
| 973 | ]), | |
| 974 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 975 | ], | |
| 976 | }), | |
| 936 | 977 | }, |
| 937 | 978 | { |
| 938 | 979 | slug: "gpt-5.3-codex-spark", |
| 939 | 980 | name: "GPT-5.3 Codex Spark", |
| 940 | 981 | isCustom: false, |
| 941 | capabilities: { | |
| 942 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | |
| 943 | supportsFastMode: true, | |
| 944 | supportsThinkingToggle: false, | |
| 945 | contextWindowOptions: [], | |
| 946 | promptInjectedEffortLevels: [], | |
| 947 | }, | |
| 982 | capabilities: createModelCapabilities({ | |
| 983 | optionDescriptors: [ | |
| 984 | selectDescriptor("reasoningEffort", "Reasoning", [ | |
| 985 | { id: "low", label: "low" }, | |
| 986 | { id: "medium", label: "medium", isDefault: true }, | |
| 987 | { id: "high", label: "high" }, | |
| 988 | ]), | |
| 989 | booleanDescriptor("fastMode", "Fast Mode"), | |
| 990 | ], | |
| 991 | }), | |
| 948 | 992 | }, |
| 949 | 993 | ]), |
| 950 | 994 | TEST_PROVIDERS[1]!, |
apps/web/src/components/chat/ProviderStatusBanner.tsx+3-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; | |
| 1 | import { type ServerProvider } from "@t3tools/contracts"; | |
| 2 | 2 | import { memo } from "react"; |
| 3 | 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 4 | 4 | import { CircleAlertIcon } from "lucide-react"; |
| 5 | import { formatProviderKindLabel } from "../../providerModels"; | |
| 5 | 6 | |
| 6 | 7 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 7 | 8 | status, |
| ... | ... | @@ -12,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 12 | 13 | return null; |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; | |
| 16 | const providerLabel = status.displayName?.trim() || formatProviderKindLabel(status.provider); | |
| 16 | 17 | const defaultMessage = |
| 17 | 18 | status.status === "error" |
| 18 | 19 | ? `${providerLabel} provider is unavailable.` |
apps/web/src/components/chat/TraitsPicker.browser.tsx deleted-821| ... | ... | @@ -1,821 +0,0 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { | |
| 4 | type ModelSelection, | |
| 5 | ClaudeModelOptions, | |
| 6 | CodexModelOptions, | |
| 7 | CursorModelOptions, | |
| 8 | DEFAULT_MODEL_BY_PROVIDER, | |
| 9 | DEFAULT_SERVER_SETTINGS, | |
| 10 | OpenCodeModelOptions, | |
| 11 | EnvironmentId, | |
| 12 | type ServerProvider, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 16 | import { page } from "vitest/browser"; | |
| 17 | import { useCallback } from "react"; | |
| 18 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 19 | import { render } from "vitest-browser-react"; | |
| 20 | ||
| 21 | import { TraitsPicker } from "./TraitsPicker"; | |
| 22 | import { | |
| 23 | COMPOSER_DRAFT_STORAGE_KEY, | |
| 24 | ComposerThreadDraftState, | |
| 25 | useComposerDraftStore, | |
| 26 | useComposerThreadDraft, | |
| 27 | useEffectiveComposerModelState, | |
| 28 | } from "../../composerDraftStore"; | |
| 29 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | |
| 30 | ||
| 31 | // ── Claude TraitsPicker tests ───────────────────────────────────────── | |
| 32 | ||
| 33 | const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | |
| 34 | const CLAUDE_THREAD_ID = ThreadId.make("thread-claude-traits"); | |
| 35 | const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); | |
| 36 | const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); | |
| 37 | const CODEX_THREAD_ID = ThreadId.make("thread-codex-traits"); | |
| 38 | const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); | |
| 39 | const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); | |
| 40 | const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | |
| 41 | { | |
| 42 | provider: "codex", | |
| 43 | enabled: true, | |
| 44 | installed: true, | |
| 45 | version: "0.1.0", | |
| 46 | status: "ready", | |
| 47 | auth: { status: "authenticated" }, | |
| 48 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 49 | slashCommands: [], | |
| 50 | skills: [], | |
| 51 | models: [ | |
| 52 | { | |
| 53 | slug: "gpt-5.4", | |
| 54 | name: "GPT-5.4", | |
| 55 | isCustom: false, | |
| 56 | capabilities: { | |
| 57 | reasoningEffortLevels: [ | |
| 58 | { value: "xhigh", label: "Extra High" }, | |
| 59 | { value: "high", label: "High", isDefault: true }, | |
| 60 | ], | |
| 61 | supportsFastMode: true, | |
| 62 | supportsThinkingToggle: false, | |
| 63 | contextWindowOptions: [], | |
| 64 | promptInjectedEffortLevels: [], | |
| 65 | }, | |
| 66 | }, | |
| 67 | ], | |
| 68 | }, | |
| 69 | { | |
| 70 | provider: "opencode", | |
| 71 | enabled: true, | |
| 72 | installed: true, | |
| 73 | version: "0.1.0", | |
| 74 | status: "ready", | |
| 75 | auth: { status: "authenticated" }, | |
| 76 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 77 | slashCommands: [], | |
| 78 | skills: [], | |
| 79 | models: [ | |
| 80 | { | |
| 81 | slug: "openai/gpt-5", | |
| 82 | name: "GPT-5", | |
| 83 | isCustom: false, | |
| 84 | capabilities: { | |
| 85 | reasoningEffortLevels: [], | |
| 86 | supportsFastMode: false, | |
| 87 | supportsThinkingToggle: false, | |
| 88 | contextWindowOptions: [], | |
| 89 | promptInjectedEffortLevels: [], | |
| 90 | variantOptions: [ | |
| 91 | { value: "low", label: "Low" }, | |
| 92 | { value: "medium", label: "Medium", isDefault: true }, | |
| 93 | ], | |
| 94 | agentOptions: [ | |
| 95 | { value: "build", label: "Build", isDefault: true }, | |
| 96 | { value: "plan", label: "Plan" }, | |
| 97 | ], | |
| 98 | }, | |
| 99 | }, | |
| 100 | ], | |
| 101 | }, | |
| 102 | { | |
| 103 | provider: "claudeAgent", | |
| 104 | enabled: true, | |
| 105 | installed: true, | |
| 106 | version: "0.1.0", | |
| 107 | status: "ready", | |
| 108 | auth: { status: "authenticated" }, | |
| 109 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 110 | slashCommands: [], | |
| 111 | skills: [], | |
| 112 | models: [ | |
| 113 | { | |
| 114 | slug: "claude-opus-4-6", | |
| 115 | name: "Claude Opus 4.6", | |
| 116 | isCustom: false, | |
| 117 | capabilities: { | |
| 118 | reasoningEffortLevels: [ | |
| 119 | { value: "low", label: "Low" }, | |
| 120 | { value: "medium", label: "Medium" }, | |
| 121 | { value: "high", label: "High", isDefault: true }, | |
| 122 | { value: "max", label: "Max" }, | |
| 123 | { value: "ultrathink", label: "Ultrathink" }, | |
| 124 | ], | |
| 125 | supportsFastMode: true, | |
| 126 | supportsThinkingToggle: false, | |
| 127 | contextWindowOptions: [], | |
| 128 | promptInjectedEffortLevels: ["ultrathink"], | |
| 129 | }, | |
| 130 | }, | |
| 131 | { | |
| 132 | slug: "claude-sonnet-4-6", | |
| 133 | name: "Claude Sonnet 4.6", | |
| 134 | isCustom: false, | |
| 135 | capabilities: { | |
| 136 | reasoningEffortLevels: [ | |
| 137 | { value: "low", label: "Low" }, | |
| 138 | { value: "medium", label: "Medium" }, | |
| 139 | { value: "high", label: "High", isDefault: true }, | |
| 140 | { value: "ultrathink", label: "Ultrathink" }, | |
| 141 | ], | |
| 142 | supportsFastMode: false, | |
| 143 | supportsThinkingToggle: false, | |
| 144 | contextWindowOptions: [], | |
| 145 | promptInjectedEffortLevels: ["ultrathink"], | |
| 146 | }, | |
| 147 | }, | |
| 148 | { | |
| 149 | slug: "claude-haiku-4-5", | |
| 150 | name: "Claude Haiku 4.5", | |
| 151 | isCustom: false, | |
| 152 | capabilities: { | |
| 153 | reasoningEffortLevels: [], | |
| 154 | supportsFastMode: false, | |
| 155 | supportsThinkingToggle: true, | |
| 156 | contextWindowOptions: [], | |
| 157 | promptInjectedEffortLevels: [], | |
| 158 | }, | |
| 159 | }, | |
| 160 | ], | |
| 161 | }, | |
| 162 | ]; | |
| 163 | const findTestProvider = (provider: ServerProvider["provider"]) => { | |
| 164 | const testProvider = TEST_PROVIDERS.find((candidate) => candidate.provider === provider); | |
| 165 | if (!testProvider) { | |
| 166 | throw new Error(`Missing test provider fixture: ${provider}`); | |
| 167 | } | |
| 168 | return testProvider; | |
| 169 | }; | |
| 170 | ||
| 171 | function ClaudeTraitsPickerHarness(props: { | |
| 172 | model: string; | |
| 173 | fallbackModelSelection: ModelSelection | null; | |
| 174 | triggerVariant?: "ghost" | "outline"; | |
| 175 | }) { | |
| 176 | const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; | |
| 177 | const setPrompt = useComposerDraftStore((store) => store.setPrompt); | |
| 178 | const { modelOptions, selectedModel } = useEffectiveComposerModelState({ | |
| 179 | threadRef: CLAUDE_THREAD_REF, | |
| 180 | providers: TEST_PROVIDERS, | |
| 181 | selectedProvider: "claudeAgent", | |
| 182 | threadModelSelection: props.fallbackModelSelection, | |
| 183 | projectModelSelection: null, | |
| 184 | settings: { | |
| 185 | ...DEFAULT_SERVER_SETTINGS, | |
| 186 | ...DEFAULT_CLIENT_SETTINGS, | |
| 187 | }, | |
| 188 | }); | |
| 189 | const handlePromptChange = useCallback( | |
| 190 | (nextPrompt: string) => { | |
| 191 | setPrompt(CLAUDE_THREAD_REF, nextPrompt); | |
| 192 | }, | |
| 193 | [setPrompt], | |
| 194 | ); | |
| 195 | ||
| 196 | return ( | |
| 197 | <TraitsPicker | |
| 198 | provider="claudeAgent" | |
| 199 | models={TEST_PROVIDERS[2]!.models} | |
| 200 | threadRef={CLAUDE_THREAD_REF} | |
| 201 | model={selectedModel ?? props.model} | |
| 202 | prompt={prompt} | |
| 203 | modelOptions={modelOptions?.claudeAgent} | |
| 204 | onPromptChange={handlePromptChange} | |
| 205 | triggerVariant={props.triggerVariant} | |
| 206 | /> | |
| 207 | ); | |
| 208 | } | |
| 209 | ||
| 210 | async function mountClaudePicker(props?: { | |
| 211 | model?: string; | |
| 212 | prompt?: string; | |
| 213 | options?: ClaudeModelOptions; | |
| 214 | fallbackModelOptions?: { | |
| 215 | effort?: "low" | "medium" | "high" | "max" | "ultrathink"; | |
| 216 | thinking?: boolean; | |
| 217 | fastMode?: boolean; | |
| 218 | } | null; | |
| 219 | skipDraftModelOptions?: boolean; | |
| 220 | triggerVariant?: "ghost" | "outline"; | |
| 221 | }) { | |
| 222 | const model = props?.model ?? "claude-opus-4-6"; | |
| 223 | const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; | |
| 224 | const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 225 | [CLAUDE_THREAD_KEY]: { | |
| 226 | prompt: props?.prompt ?? "", | |
| 227 | images: [], | |
| 228 | nonPersistedImageIds: [], | |
| 229 | persistedAttachments: [], | |
| 230 | terminalContexts: [], | |
| 231 | modelSelectionByProvider: props?.skipDraftModelOptions | |
| 232 | ? {} | |
| 233 | : { | |
| 234 | claudeAgent: { | |
| 235 | provider: "claudeAgent", | |
| 236 | model, | |
| 237 | ...(claudeOptions && Object.keys(claudeOptions).length > 0 | |
| 238 | ? { options: claudeOptions } | |
| 239 | : {}), | |
| 240 | }, | |
| 241 | }, | |
| 242 | activeProvider: "claudeAgent", | |
| 243 | runtimeMode: null, | |
| 244 | interactionMode: null, | |
| 245 | }, | |
| 246 | }; | |
| 247 | useComposerDraftStore.setState({ | |
| 248 | draftsByThreadKey, | |
| 249 | draftThreadsByThreadKey: {}, | |
| 250 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 251 | }); | |
| 252 | const host = document.createElement("div"); | |
| 253 | document.body.append(host); | |
| 254 | const fallbackModelSelection = | |
| 255 | props?.fallbackModelOptions !== undefined | |
| 256 | ? ({ | |
| 257 | provider: "claudeAgent", | |
| 258 | model, | |
| 259 | ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}), | |
| 260 | } satisfies ModelSelection) | |
| 261 | : null; | |
| 262 | const screen = await render( | |
| 263 | <ClaudeTraitsPickerHarness | |
| 264 | model={model} | |
| 265 | fallbackModelSelection={fallbackModelSelection} | |
| 266 | {...(props?.triggerVariant ? { triggerVariant: props.triggerVariant } : {})} | |
| 267 | />, | |
| 268 | { container: host }, | |
| 269 | ); | |
| 270 | ||
| 271 | const cleanup = async () => { | |
| 272 | await screen.unmount(); | |
| 273 | host.remove(); | |
| 274 | }; | |
| 275 | ||
| 276 | return { | |
| 277 | [Symbol.asyncDispose]: cleanup, | |
| 278 | cleanup, | |
| 279 | }; | |
| 280 | } | |
| 281 | ||
| 282 | describe("TraitsPicker (Claude)", () => { | |
| 283 | afterEach(() => { | |
| 284 | document.body.innerHTML = ""; | |
| 285 | useComposerDraftStore.setState({ | |
| 286 | draftsByThreadKey: {}, | |
| 287 | draftThreadsByThreadKey: {}, | |
| 288 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 289 | stickyModelSelectionByProvider: {}, | |
| 290 | }); | |
| 291 | }); | |
| 292 | ||
| 293 | it("shows fast mode controls for Opus", async () => { | |
| 294 | await using _ = await mountClaudePicker(); | |
| 295 | ||
| 296 | await page.getByRole("button").click(); | |
| 297 | ||
| 298 | await vi.waitFor(() => { | |
| 299 | const text = document.body.textContent ?? ""; | |
| 300 | expect(text).toContain("Fast Mode"); | |
| 301 | expect(text).toContain("off"); | |
| 302 | expect(text).toContain("on"); | |
| 303 | }); | |
| 304 | }); | |
| 305 | ||
| 306 | it("hides fast mode controls for non-Opus models", async () => { | |
| 307 | await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" }); | |
| 308 | ||
| 309 | await page.getByRole("button").click(); | |
| 310 | ||
| 311 | await vi.waitFor(() => { | |
| 312 | expect(document.body.textContent ?? "").not.toContain("Fast Mode"); | |
| 313 | }); | |
| 314 | }); | |
| 315 | ||
| 316 | it("shows only the provided effort options", async () => { | |
| 317 | await using _ = await mountClaudePicker({ | |
| 318 | model: "claude-sonnet-4-6", | |
| 319 | }); | |
| 320 | ||
| 321 | await page.getByRole("button").click(); | |
| 322 | ||
| 323 | await vi.waitFor(() => { | |
| 324 | const text = document.body.textContent ?? ""; | |
| 325 | expect(text).toContain("Low"); | |
| 326 | expect(text).toContain("Medium"); | |
| 327 | expect(text).toContain("High"); | |
| 328 | expect(text).not.toContain("Max"); | |
| 329 | expect(text).toContain("Ultrathink"); | |
| 330 | }); | |
| 331 | }); | |
| 332 | ||
| 333 | it("shows a th inking on/off dropdown for Haiku", async () => { | |
| 334 | await using _ = await mountClaudePicker({ | |
| 335 | model: "claude-haiku-4-5", | |
| 336 | options: { thinking: true }, | |
| 337 | }); | |
| 338 | ||
| 339 | await vi.waitFor(() => { | |
| 340 | expect(document.body.textContent ?? "").toContain("Thinking On"); | |
| 341 | }); | |
| 342 | await page.getByRole("button").click(); | |
| 343 | ||
| 344 | await vi.waitFor(() => { | |
| 345 | const text = document.body.textContent ?? ""; | |
| 346 | expect(text).toContain("Thinking"); | |
| 347 | expect(text).toContain("On (default)"); | |
| 348 | expect(text).toContain("Off"); | |
| 349 | }); | |
| 350 | }); | |
| 351 | ||
| 352 | it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { | |
| 353 | await using _ = await mountClaudePicker({ | |
| 354 | model: "claude-opus-4-6", | |
| 355 | options: { effort: "high" }, | |
| 356 | prompt: "Ultrathink:\nInvestigate this", | |
| 357 | }); | |
| 358 | ||
| 359 | await vi.waitFor(() => { | |
| 360 | expect(document.body.textContent ?? "").toContain("Ultrathink"); | |
| 361 | expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); | |
| 362 | }); | |
| 363 | await page.getByRole("button").click(); | |
| 364 | ||
| 365 | await vi.waitFor(() => { | |
| 366 | const text = document.body.textContent ?? ""; | |
| 367 | expect(text).toContain("Effort"); | |
| 368 | expect(text).not.toContain("ultrathink"); | |
| 369 | }); | |
| 370 | }); | |
| 371 | ||
| 372 | it("warns when ultrathink appears in prompt body text", async () => { | |
| 373 | await using _ = await mountClaudePicker({ | |
| 374 | model: "claude-opus-4-6", | |
| 375 | options: { effort: "high" }, | |
| 376 | prompt: "Ultrathink:\nplease ultrathink about this problem", | |
| 377 | }); | |
| 378 | ||
| 379 | await page.getByRole("button").click(); | |
| 380 | ||
| 381 | await vi.waitFor(() => { | |
| 382 | const text = document.body.textContent ?? ""; | |
| 383 | expect(text).toContain( | |
| 384 | 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', | |
| 385 | ); | |
| 386 | }); | |
| 387 | }); | |
| 388 | ||
| 389 | it("persists sticky claude model options when traits change", async () => { | |
| 390 | await using _ = await mountClaudePicker({ | |
| 391 | model: "claude-opus-4-6", | |
| 392 | options: { effort: "medium", fastMode: false }, | |
| 393 | }); | |
| 394 | ||
| 395 | await page.getByRole("button").click(); | |
| 396 | await page.getByRole("menuitemradio", { name: "Max" }).click(); | |
| 397 | ||
| 398 | expect( | |
| 399 | useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, | |
| 400 | ).toMatchObject({ | |
| 401 | provider: "claudeAgent", | |
| 402 | options: { | |
| 403 | effort: "max", | |
| 404 | }, | |
| 405 | }); | |
| 406 | }); | |
| 407 | ||
| 408 | it("accepts outline trigger styling", async () => { | |
| 409 | await using _ = await mountClaudePicker({ | |
| 410 | triggerVariant: "outline", | |
| 411 | }); | |
| 412 | ||
| 413 | const button = document.querySelector("button"); | |
| 414 | if (!(button instanceof HTMLButtonElement)) { | |
| 415 | throw new Error("Expected traits trigger button to be rendered."); | |
| 416 | } | |
| 417 | expect(button.className).toContain("border-input"); | |
| 418 | expect(button.className).toContain("bg-popover"); | |
| 419 | }); | |
| 420 | }); | |
| 421 | ||
| 422 | // ── Codex TraitsPicker tests ────────────────────────────────────────── | |
| 423 | ||
| 424 | async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { | |
| 425 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; | |
| 426 | const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 427 | [CODEX_THREAD_KEY]: { | |
| 428 | prompt: "", | |
| 429 | images: [], | |
| 430 | nonPersistedImageIds: [], | |
| 431 | persistedAttachments: [], | |
| 432 | terminalContexts: [], | |
| 433 | modelSelectionByProvider: { | |
| 434 | codex: { | |
| 435 | provider: "codex", | |
| 436 | model, | |
| 437 | ...(props.options ? { options: props.options } : {}), | |
| 438 | }, | |
| 439 | }, | |
| 440 | activeProvider: "codex", | |
| 441 | runtimeMode: null, | |
| 442 | interactionMode: null, | |
| 443 | }, | |
| 444 | }; | |
| 445 | ||
| 446 | useComposerDraftStore.setState({ | |
| 447 | draftsByThreadKey, | |
| 448 | draftThreadsByThreadKey: {}, | |
| 449 | logicalProjectDraftThreadKeyByLogicalProjectKey: { | |
| 450 | "environment-local:project-codex-traits": CODEX_THREAD_KEY, | |
| 451 | }, | |
| 452 | }); | |
| 453 | const host = document.createElement("div"); | |
| 454 | document.body.append(host); | |
| 455 | const screen = await render( | |
| 456 | <TraitsPicker | |
| 457 | provider="codex" | |
| 458 | models={TEST_PROVIDERS[0]!.models} | |
| 459 | threadRef={CODEX_THREAD_REF} | |
| 460 | model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex} | |
| 461 | prompt="" | |
| 462 | modelOptions={props.options} | |
| 463 | onPromptChange={() => {}} | |
| 464 | />, | |
| 465 | { container: host }, | |
| 466 | ); | |
| 467 | ||
| 468 | const cleanup = async () => { | |
| 469 | await screen.unmount(); | |
| 470 | host.remove(); | |
| 471 | }; | |
| 472 | ||
| 473 | return { | |
| 474 | [Symbol.asyncDispose]: cleanup, | |
| 475 | cleanup, | |
| 476 | }; | |
| 477 | } | |
| 478 | ||
| 479 | async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) { | |
| 480 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor; | |
| 481 | const cursorThreadId = ThreadId.make("thread-cursor-traits"); | |
| 482 | const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId); | |
| 483 | const cursorThreadKey = scopedThreadKey(cursorThreadRef); | |
| 484 | const host = document.createElement("div"); | |
| 485 | document.body.append(host); | |
| 486 | ||
| 487 | useComposerDraftStore.setState({ | |
| 488 | draftsByThreadKey: { | |
| 489 | [cursorThreadKey]: { | |
| 490 | prompt: "", | |
| 491 | images: [], | |
| 492 | nonPersistedImageIds: [], | |
| 493 | persistedAttachments: [], | |
| 494 | terminalContexts: [], | |
| 495 | modelSelectionByProvider: { | |
| 496 | cursor: { | |
| 497 | provider: "cursor", | |
| 498 | model, | |
| 499 | ...(props.options ? { options: props.options } : {}), | |
| 500 | }, | |
| 501 | }, | |
| 502 | activeProvider: "cursor", | |
| 503 | runtimeMode: null, | |
| 504 | interactionMode: null, | |
| 505 | }, | |
| 506 | }, | |
| 507 | draftThreadsByThreadKey: {}, | |
| 508 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 509 | }); | |
| 510 | ||
| 511 | const screen = await render( | |
| 512 | <TraitsPicker | |
| 513 | provider="cursor" | |
| 514 | models={[ | |
| 515 | { | |
| 516 | slug: "composer-2", | |
| 517 | name: "Composer 2", | |
| 518 | isCustom: false, | |
| 519 | capabilities: { | |
| 520 | reasoningEffortLevels: [], | |
| 521 | supportsFastMode: true, | |
| 522 | supportsThinkingToggle: false, | |
| 523 | contextWindowOptions: [], | |
| 524 | promptInjectedEffortLevels: [], | |
| 525 | }, | |
| 526 | }, | |
| 527 | { | |
| 528 | slug: "claude-opus-4-6", | |
| 529 | name: "Opus 4.6", | |
| 530 | isCustom: false, | |
| 531 | capabilities: { | |
| 532 | reasoningEffortLevels: [], | |
| 533 | supportsFastMode: true, | |
| 534 | supportsThinkingToggle: false, | |
| 535 | contextWindowOptions: [ | |
| 536 | { value: "200k", label: "200K", isDefault: true }, | |
| 537 | { value: "1m", label: "1M" }, | |
| 538 | ], | |
| 539 | promptInjectedEffortLevels: [], | |
| 540 | }, | |
| 541 | }, | |
| 542 | ]} | |
| 543 | threadRef={cursorThreadRef} | |
| 544 | model={model} | |
| 545 | prompt="" | |
| 546 | modelOptions={props.options} | |
| 547 | onPromptChange={() => {}} | |
| 548 | />, | |
| 549 | { container: host }, | |
| 550 | ); | |
| 551 | ||
| 552 | const cleanup = async () => { | |
| 553 | await screen.unmount(); | |
| 554 | host.remove(); | |
| 555 | }; | |
| 556 | ||
| 557 | return { | |
| 558 | [Symbol.asyncDispose]: cleanup, | |
| 559 | cleanup, | |
| 560 | }; | |
| 561 | } | |
| 562 | ||
| 563 | describe("TraitsPicker (Codex)", () => { | |
| 564 | afterEach(() => { | |
| 565 | document.body.innerHTML = ""; | |
| 566 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); | |
| 567 | useComposerDraftStore.setState({ | |
| 568 | draftsByThreadKey: {}, | |
| 569 | draftThreadsByThreadKey: {}, | |
| 570 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 571 | stickyModelSelectionByProvider: {}, | |
| 572 | }); | |
| 573 | }); | |
| 574 | ||
| 575 | it("shows fast mode controls", async () => { | |
| 576 | await using _ = await mountCodexPicker({ | |
| 577 | options: { fastMode: false }, | |
| 578 | }); | |
| 579 | ||
| 580 | await page.getByRole("button").click(); | |
| 581 | ||
| 582 | await vi.waitFor(() => { | |
| 583 | const text = document.body.textContent ?? ""; | |
| 584 | expect(text).toContain("Fast Mode"); | |
| 585 | expect(text).toContain("off"); | |
| 586 | expect(text).toContain("on"); | |
| 587 | }); | |
| 588 | }); | |
| 589 | ||
| 590 | it("shows Fast in the trigger label when fast mode is active", async () => { | |
| 591 | await using _ = await mountCodexPicker({ | |
| 592 | options: { fastMode: true }, | |
| 593 | }); | |
| 594 | ||
| 595 | await vi.waitFor(() => { | |
| 596 | expect(document.body.textContent ?? "").toContain("High · Fast"); | |
| 597 | }); | |
| 598 | }); | |
| 599 | ||
| 600 | it("shows only the provided effort options", async () => { | |
| 601 | await using _ = await mountCodexPicker({ | |
| 602 | options: { fastMode: false }, | |
| 603 | }); | |
| 604 | ||
| 605 | await page.getByRole("button").click(); | |
| 606 | ||
| 607 | await vi.waitFor(() => { | |
| 608 | const text = document.body.textContent ?? ""; | |
| 609 | expect(text).toContain("Extra High"); | |
| 610 | expect(text).toContain("High"); | |
| 611 | expect(text).not.toContain("Low"); | |
| 612 | expect(text).not.toContain("Medium"); | |
| 613 | }); | |
| 614 | }); | |
| 615 | ||
| 616 | it("persists sticky codex model options when traits change", async () => { | |
| 617 | await using _ = await mountCodexPicker({ | |
| 618 | options: { fastMode: false }, | |
| 619 | }); | |
| 620 | ||
| 621 | await page.getByRole("button").click(); | |
| 622 | await page.getByRole("menuitemradio", { name: "on" }).click(); | |
| 623 | ||
| 624 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ | |
| 625 | provider: "codex", | |
| 626 | options: { fastMode: true }, | |
| 627 | }); | |
| 628 | }); | |
| 629 | }); | |
| 630 | ||
| 631 | // ── OpenCode TraitsPicker tests ─────────────────────────────────────── | |
| 632 | ||
| 633 | async function mountOpenCodePicker(props: { | |
| 634 | model?: string; | |
| 635 | options?: OpenCodeModelOptions; | |
| 636 | models?: ServerProvider["models"]; | |
| 637 | }) { | |
| 638 | const threadId = ThreadId.make("thread-opencode-traits"); | |
| 639 | const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | |
| 640 | const threadKey = scopedThreadKey(threadRef); | |
| 641 | const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode; | |
| 642 | const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 643 | [threadKey]: { | |
| 644 | prompt: "", | |
| 645 | images: [], | |
| 646 | nonPersistedImageIds: [], | |
| 647 | persistedAttachments: [], | |
| 648 | terminalContexts: [], | |
| 649 | modelSelectionByProvider: { | |
| 650 | opencode: { | |
| 651 | provider: "opencode", | |
| 652 | model, | |
| 653 | ...(props.options ? { options: props.options } : {}), | |
| 654 | }, | |
| 655 | }, | |
| 656 | activeProvider: "opencode", | |
| 657 | runtimeMode: null, | |
| 658 | interactionMode: null, | |
| 659 | }, | |
| 660 | }; | |
| 661 | ||
| 662 | useComposerDraftStore.setState({ | |
| 663 | draftsByThreadKey, | |
| 664 | draftThreadsByThreadKey: {}, | |
| 665 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 666 | }); | |
| 667 | const host = document.createElement("div"); | |
| 668 | document.body.append(host); | |
| 669 | const screen = await render( | |
| 670 | <TraitsPicker | |
| 671 | provider="opencode" | |
| 672 | models={props.models ?? findTestProvider("opencode").models} | |
| 673 | threadRef={threadRef} | |
| 674 | model={model} | |
| 675 | prompt="" | |
| 676 | modelOptions={props.options} | |
| 677 | onPromptChange={() => {}} | |
| 678 | />, | |
| 679 | { container: host }, | |
| 680 | ); | |
| 681 | ||
| 682 | const cleanup = async () => { | |
| 683 | await screen.unmount(); | |
| 684 | host.remove(); | |
| 685 | }; | |
| 686 | ||
| 687 | return { | |
| 688 | [Symbol.asyncDispose]: cleanup, | |
| 689 | cleanup, | |
| 690 | }; | |
| 691 | } | |
| 692 | ||
| 693 | describe("TraitsPicker (OpenCode)", () => { | |
| 694 | afterEach(() => { | |
| 695 | document.body.innerHTML = ""; | |
| 696 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); | |
| 697 | useComposerDraftStore.setState({ | |
| 698 | draftsByThreadKey: {}, | |
| 699 | draftThreadsByThreadKey: {}, | |
| 700 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 701 | stickyModelSelectionByProvider: {}, | |
| 702 | }); | |
| 703 | }); | |
| 704 | ||
| 705 | it("shows the selected agent label with capitalization in the trigger", async () => { | |
| 706 | await using _ = await mountOpenCodePicker({ | |
| 707 | options: { | |
| 708 | variant: "medium", | |
| 709 | agent: "plan", | |
| 710 | }, | |
| 711 | }); | |
| 712 | ||
| 713 | await vi.waitFor(() => { | |
| 714 | const text = document.body.textContent ?? ""; | |
| 715 | expect(text).toContain("Medium · Plan"); | |
| 716 | expect(text).not.toContain("Medium · plan"); | |
| 717 | }); | |
| 718 | }); | |
| 719 | ||
| 720 | it("does not show a leading separator when only agent options are available", async () => { | |
| 721 | await using _ = await mountOpenCodePicker({ | |
| 722 | model: "openai/gpt-5.4", | |
| 723 | models: [ | |
| 724 | { | |
| 725 | slug: "openai/gpt-5.4", | |
| 726 | name: "OpenAI · GPT-5.4", | |
| 727 | isCustom: false, | |
| 728 | capabilities: { | |
| 729 | reasoningEffortLevels: [], | |
| 730 | supportsFastMode: false, | |
| 731 | supportsThinkingToggle: false, | |
| 732 | contextWindowOptions: [], | |
| 733 | promptInjectedEffortLevels: [], | |
| 734 | variantOptions: [], | |
| 735 | agentOptions: [ | |
| 736 | { value: "build", label: "Build", isDefault: true }, | |
| 737 | { value: "plan", label: "Plan" }, | |
| 738 | ], | |
| 739 | }, | |
| 740 | }, | |
| 741 | ], | |
| 742 | }); | |
| 743 | ||
| 744 | await page.getByRole("button").click(); | |
| 745 | ||
| 746 | await vi.waitFor(() => { | |
| 747 | const text = document.body.textContent ?? ""; | |
| 748 | expect(text).toContain("Agent"); | |
| 749 | expect(text).toContain("Build (default)"); | |
| 750 | expect(text).toContain("Plan"); | |
| 751 | expect(document.querySelectorAll('[data-slot="menu-separator"]')).toHaveLength(0); | |
| 752 | }); | |
| 753 | }); | |
| 754 | }); | |
| 755 | ||
| 756 | describe("TraitsPicker (Cursor)", () => { | |
| 757 | afterEach(() => { | |
| 758 | document.body.innerHTML = ""; | |
| 759 | localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); | |
| 760 | useComposerDraftStore.setState({ | |
| 761 | draftsByThreadKey: {}, | |
| 762 | draftThreadsByThreadKey: {}, | |
| 763 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 764 | stickyModelSelectionByProvider: {}, | |
| 765 | }); | |
| 766 | }); | |
| 767 | ||
| 768 | it("uses the selected fast mode menu label for the trigger in fast-only state", async () => { | |
| 769 | await using _ = await mountCursorPicker({ | |
| 770 | model: "composer-2", | |
| 771 | options: { fastMode: false }, | |
| 772 | }); | |
| 773 | ||
| 774 | await vi.waitFor(() => { | |
| 775 | const text = document.body.textContent ?? ""; | |
| 776 | expect(text).toContain("Normal"); | |
| 777 | }); | |
| 778 | ||
| 779 | await page.getByRole("button").click(); | |
| 780 | ||
| 781 | await vi.waitFor(() => { | |
| 782 | const text = document.body.textContent ?? ""; | |
| 783 | expect(text).toContain("Fast Mode"); | |
| 784 | expect(text).toContain("off"); | |
| 785 | expect(text).toContain("on"); | |
| 786 | }); | |
| 787 | }); | |
| 788 | ||
| 789 | it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => { | |
| 790 | await using _ = await mountCursorPicker({ | |
| 791 | model: "claude-opus-4-6", | |
| 792 | options: { fastMode: false }, | |
| 793 | }); | |
| 794 | ||
| 795 | await vi.waitFor(() => { | |
| 796 | const text = document.body.textContent ?? ""; | |
| 797 | expect(text).toContain("Normal"); | |
| 798 | }); | |
| 799 | ||
| 800 | await page.getByRole("button").click(); | |
| 801 | ||
| 802 | await vi.waitFor(() => { | |
| 803 | const text = document.body.textContent ?? ""; | |
| 804 | expect(text).toContain("Fast Mode"); | |
| 805 | expect(text).toContain("Context Window"); | |
| 806 | expect(text).toContain("200K (default)"); | |
| 807 | expect(text).toContain("1M"); | |
| 808 | }); | |
| 809 | }); | |
| 810 | ||
| 811 | it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => { | |
| 812 | await using _ = await mountCursorPicker({ | |
| 813 | model: "claude-opus-4-6", | |
| 814 | options: { fastMode: false, contextWindow: "1m" }, | |
| 815 | }); | |
| 816 | ||
| 817 | await vi.waitFor(() => { | |
| 818 | expect(document.body.textContent ?? "").toContain("Normal · 1M"); | |
| 819 | }); | |
| 820 | }); | |
| 821 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+163-337| ... | ... | @@ -1,21 +1,17 @@ |
| 1 | 1 | import { |
| 2 | type ClaudeModelOptions, | |
| 3 | type CodexModelOptions, | |
| 4 | type CursorModelOptions, | |
| 5 | type OpenCodeModelOptions, | |
| 6 | 2 | type ProviderKind, |
| 7 | type ProviderModelOptions, | |
| 3 | type ProviderOptionDescriptor, | |
| 4 | type ProviderOptionSelection, | |
| 8 | 5 | type ScopedThreadRef, |
| 9 | 6 | type ServerProviderModel, |
| 10 | 7 | } from "@t3tools/contracts"; |
| 11 | 8 | import { |
| 12 | 9 | applyClaudePromptEffortPrefix, |
| 10 | buildProviderOptionSelectionsFromDescriptors, | |
| 11 | getProviderOptionCurrentLabel, | |
| 12 | getProviderOptionCurrentValue, | |
| 13 | getProviderOptionDescriptors, | |
| 13 | 14 | isClaudeUltrathinkPrompt, |
| 14 | trimOrNull, | |
| 15 | getDefaultEffort, | |
| 16 | getDefaultContextWindow, | |
| 17 | hasContextWindowOption, | |
| 18 | resolveEffort, | |
| 19 | 15 | } from "@t3tools/shared/model"; |
| 20 | 16 | import { memo, useCallback, useState } from "react"; |
| 21 | 17 | import type { VariantProps } from "class-variance-authority"; |
| ... | ... | @@ -34,12 +30,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; |
| 34 | 30 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 35 | 31 | import { cn } from "~/lib/utils"; |
| 36 | 32 | |
| 37 | type ProviderOptions = ProviderModelOptions[ProviderKind]; | |
| 38 | type NamedOption = { | |
| 39 | value: string; | |
| 40 | label: string; | |
| 41 | isDefault?: boolean | undefined; | |
| 42 | }; | |
| 33 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; | |
| 43 | 34 | |
| 44 | 35 | type TraitsPersistence = |
| 45 | 36 | | { |
| ... | ... | @@ -54,62 +45,34 @@ type TraitsPersistence = |
| 54 | 45 | |
| 55 | 46 | const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; |
| 56 | 47 | |
| 57 | function getRawEffort( | |
| 58 | provider: ProviderKind, | |
| 59 | modelOptions: ProviderOptions | null | undefined, | |
| 60 | ): string | null { | |
| 61 | if (provider === "codex") { | |
| 62 | return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); | |
| 63 | } | |
| 64 | if (provider === "cursor") { | |
| 65 | return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning); | |
| 66 | } | |
| 67 | if (provider === "opencode") { | |
| 68 | return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); | |
| 69 | } | |
| 70 | return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); | |
| 71 | } | |
| 72 | ||
| 73 | function getEffortKey(provider: ProviderKind): string { | |
| 74 | if (provider === "codex") return "reasoningEffort"; | |
| 75 | if (provider === "cursor") return "reasoning"; | |
| 76 | if (provider === "opencode") return "variant"; | |
| 77 | return "effort"; | |
| 78 | } | |
| 79 | ||
| 80 | function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { | |
| 81 | return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); | |
| 82 | } | |
| 83 | ||
| 84 | function resolveNamedOption( | |
| 85 | options: ReadonlyArray<NamedOption>, | |
| 86 | raw: string | null, | |
| 87 | ): NamedOption | null { | |
| 88 | if (raw) { | |
| 89 | const matchingOption = options.find((option) => option.value === raw); | |
| 90 | if (matchingOption) { | |
| 91 | return matchingOption; | |
| 92 | } | |
| 93 | } | |
| 94 | return options.find((option) => option.isDefault) ?? options[0] ?? null; | |
| 48 | function replaceDescriptorCurrentValue( | |
| 49 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | |
| 50 | descriptorId: string, | |
| 51 | currentValue: string | boolean | undefined, | |
| 52 | ): ReadonlyArray<ProviderOptionDescriptor> { | |
| 53 | return descriptors.map((descriptor) => | |
| 54 | descriptor.id !== descriptorId | |
| 55 | ? descriptor | |
| 56 | : descriptor.type === "boolean" | |
| 57 | ? { | |
| 58 | ...descriptor, | |
| 59 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | |
| 60 | } | |
| 61 | : { | |
| 62 | ...descriptor, | |
| 63 | ...(typeof currentValue === "string" ? { currentValue } : {}), | |
| 64 | }, | |
| 65 | ); | |
| 95 | 66 | } |
| 96 | 67 | |
| 97 | function getRawContextWindow( | |
| 98 | provider: ProviderKind, | |
| 99 | modelOptions: ProviderOptions | null | undefined, | |
| 68 | function getDescriptorStringValue( | |
| 69 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null, | |
| 100 | 70 | ): string | null { |
| 101 | if (modelOptions && "contextWindow" in modelOptions) { | |
| 102 | return trimOrNull(modelOptions.contextWindow); | |
| 71 | if (!descriptor) { | |
| 72 | return null; | |
| 103 | 73 | } |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | function buildNextOptions( | |
| 108 | provider: ProviderKind, | |
| 109 | modelOptions: ProviderOptions | null | undefined, | |
| 110 | patch: Record<string, unknown>, | |
| 111 | ): ProviderOptions { | |
| 112 | return { ...(modelOptions as Record<string, unknown> | undefined), ...patch } as ProviderOptions; | |
| 74 | const value = getProviderOptionCurrentValue(descriptor); | |
| 75 | return typeof value === "string" ? value : null; | |
| 113 | 76 | } |
| 114 | 77 | |
| 115 | 78 | function getSelectedTraits( |
| ... | ... | @@ -121,71 +84,68 @@ function getSelectedTraits( |
| 121 | 84 | allowPromptInjectedEffort: boolean, |
| 122 | 85 | ) { |
| 123 | 86 | const caps = getProviderModelCapabilities(models, model, provider); |
| 124 | const effortLevels = | |
| 125 | provider === "opencode" | |
| 126 | ? (caps.variantOptions ?? []) | |
| 127 | : allowPromptInjectedEffort | |
| 128 | ? caps.reasoningEffortLevels | |
| 129 | : caps.reasoningEffortLevels.filter( | |
| 130 | (option) => !caps.promptInjectedEffortLevels.includes(option.value), | |
| 131 | ); | |
| 132 | ||
| 133 | // Resolve effort from options (provider-specific key) | |
| 134 | const rawEffort = getRawEffort(provider, modelOptions); | |
| 135 | const effort = | |
| 136 | provider === "opencode" | |
| 137 | ? (resolveNamedOption(effortLevels, rawEffort)?.value ?? null) | |
| 138 | : (resolveEffort(caps, rawEffort) ?? null); | |
| 139 | ||
| 140 | // Thinking toggle (only for models that support it) | |
| 141 | const thinkingEnabled = caps.supportsThinkingToggle | |
| 142 | ? modelOptions && "thinking" in modelOptions | |
| 143 | ? modelOptions.thinking === true | |
| 144 | : null | |
| 145 | : null; | |
| 146 | ||
| 147 | // Fast mode | |
| 148 | const fastModeEnabled = | |
| 149 | caps.supportsFastMode && | |
| 150 | (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; | |
| 151 | ||
| 152 | // Context window | |
| 153 | const contextWindowOptions = caps.contextWindowOptions; | |
| 154 | const rawContextWindow = getRawContextWindow(provider, modelOptions); | |
| 155 | const defaultContextWindow = getDefaultContextWindow(caps); | |
| 156 | const contextWindow = | |
| 157 | rawContextWindow && hasContextWindowOption(caps, rawContextWindow) | |
| 158 | ? rawContextWindow | |
| 159 | : defaultContextWindow; | |
| 87 | const descriptors = getProviderOptionDescriptors({ | |
| 88 | caps, | |
| 89 | selections: modelOptions, | |
| 90 | }); | |
| 91 | const selectDescriptors = descriptors.filter( | |
| 92 | (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "select" }> => | |
| 93 | descriptor.type === "select", | |
| 94 | ); | |
| 95 | const booleanDescriptors = descriptors.filter( | |
| 96 | (descriptor): descriptor is Extract<ProviderOptionDescriptor, { type: "boolean" }> => | |
| 97 | descriptor.type === "boolean", | |
| 98 | ); | |
| 99 | const primarySelectDescriptor = selectDescriptors[0] ?? null; | |
| 100 | const contextWindowDescriptor = | |
| 101 | selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null; | |
| 102 | const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null; | |
| 103 | const fastModeDescriptor = | |
| 104 | booleanDescriptors.find((descriptor) => descriptor.id === "fastMode") ?? null; | |
| 105 | const thinkingDescriptor = | |
| 106 | booleanDescriptors.find((descriptor) => descriptor.id === "thinking") ?? null; | |
| 160 | 107 | |
| 161 | 108 | // Prompt-controlled effort (e.g. ultrathink in prompt text) |
| 162 | 109 | const ultrathinkPromptControlled = |
| 163 | 110 | allowPromptInjectedEffort && |
| 164 | caps.promptInjectedEffortLevels.length > 0 && | |
| 111 | (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && | |
| 165 | 112 | isClaudeUltrathinkPrompt(prompt); |
| 166 | 113 | |
| 167 | 114 | // Check if "ultrathink" appears in the body text (not just our prefix) |
| 168 | 115 | const ultrathinkInBodyText = |
| 169 | 116 | ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); |
| 170 | ||
| 171 | const agentOptions = caps.agentOptions ?? []; | |
| 172 | const selectedAgentOption = | |
| 173 | provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null; | |
| 117 | const effort = | |
| 118 | (ultrathinkPromptControlled | |
| 119 | ? "ultrathink" | |
| 120 | : getDescriptorStringValue(primarySelectDescriptor)) ?? null; | |
| 121 | const thinkingEnabled = | |
| 122 | typeof thinkingDescriptor?.currentValue === "boolean" ? thinkingDescriptor.currentValue : null; | |
| 123 | const fastModeEnabled = | |
| 124 | typeof fastModeDescriptor?.currentValue === "boolean" ? fastModeDescriptor.currentValue : false; | |
| 125 | const contextWindow = getDescriptorStringValue(contextWindowDescriptor); | |
| 126 | const selectedAgent = getDescriptorStringValue(agentDescriptor); | |
| 127 | const selectedAgentLabel = agentDescriptor | |
| 128 | ? getProviderOptionCurrentLabel(agentDescriptor) | |
| 129 | : null; | |
| 174 | 130 | |
| 175 | 131 | return { |
| 176 | 132 | caps, |
| 133 | descriptors, | |
| 134 | selectDescriptors, | |
| 135 | booleanDescriptors, | |
| 136 | primarySelectDescriptor, | |
| 137 | contextWindowDescriptor, | |
| 138 | agentDescriptor, | |
| 139 | fastModeDescriptor, | |
| 140 | thinkingDescriptor, | |
| 177 | 141 | effort, |
| 178 | effortLevels, | |
| 179 | 142 | thinkingEnabled, |
| 180 | 143 | fastModeEnabled, |
| 181 | contextWindowOptions, | |
| 182 | 144 | contextWindow, |
| 183 | defaultContextWindow, | |
| 184 | 145 | ultrathinkPromptControlled, |
| 185 | 146 | ultrathinkInBodyText, |
| 186 | agentOptions, | |
| 187 | selectedAgent: selectedAgentOption?.value ?? null, | |
| 188 | selectedAgentLabel: selectedAgentOption?.label ?? null, | |
| 147 | selectedAgent, | |
| 148 | selectedAgentLabel, | |
| 189 | 149 | }; |
| 190 | 150 | } |
| 191 | 151 | |
| ... | ... | @@ -206,11 +166,11 @@ function getTraitsSectionVisibility(input: { |
| 206 | 166 | input.allowPromptInjectedEffort ?? true, |
| 207 | 167 | ); |
| 208 | 168 | |
| 209 | const showEffort = selected.effort !== null; | |
| 210 | const showThinking = selected.thinkingEnabled !== null; | |
| 211 | const showFastMode = selected.caps.supportsFastMode; | |
| 212 | const showContextWindow = selected.contextWindowOptions.length > 1; | |
| 213 | const showAgent = selected.agentOptions.length > 0; | |
| 169 | const showEffort = selected.primarySelectDescriptor !== null; | |
| 170 | const showThinking = selected.thinkingDescriptor !== null; | |
| 171 | const showFastMode = selected.fastModeDescriptor !== null; | |
| 172 | const showContextWindow = selected.contextWindowDescriptor !== null; | |
| 173 | const showAgent = selected.agentDescriptor !== null; | |
| 214 | 174 | |
| 215 | 175 | return { |
| 216 | 176 | ...selected, |
| ... | ... | @@ -275,22 +235,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 275 | 235 | [model, persistence, provider, setProviderModelOptions], |
| 276 | 236 | ); |
| 277 | 237 | const { |
| 278 | caps, | |
| 279 | effort, | |
| 280 | effortLevels, | |
| 281 | thinkingEnabled, | |
| 282 | fastModeEnabled, | |
| 283 | contextWindowOptions, | |
| 284 | contextWindow, | |
| 285 | defaultContextWindow, | |
| 238 | descriptors, | |
| 239 | selectDescriptors, | |
| 240 | booleanDescriptors, | |
| 241 | primarySelectDescriptor, | |
| 286 | 242 | ultrathinkPromptControlled, |
| 287 | showEffort, | |
| 288 | showThinking, | |
| 289 | showFastMode, | |
| 290 | showContextWindow, | |
| 291 | 243 | ultrathinkInBodyText, |
| 292 | agentOptions, | |
| 293 | selectedAgent, | |
| 294 | 244 | hasAnyControls, |
| 295 | 245 | } = getTraitsSectionVisibility({ |
| 296 | 246 | provider, |
| ... | ... | @@ -300,53 +250,30 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 300 | 250 | modelOptions, |
| 301 | 251 | allowPromptInjectedEffort, |
| 302 | 252 | }); |
| 303 | const defaultEffort = getDefaultEffort(caps); | |
| 304 | const showsEffortSection = showEffort; | |
| 305 | const showsThinkingSection = !showEffort && showThinking; | |
| 306 | const showsFastModeSection = showFastMode; | |
| 307 | const showsContextWindowSection = showContextWindow; | |
| 308 | const hasSectionsBeforeAgent = | |
| 309 | showsEffortSection || showsThinkingSection || showsFastModeSection || showsContextWindowSection; | |
| 253 | const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => { | |
| 254 | updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); | |
| 255 | }; | |
| 310 | 256 | |
| 311 | const handleEffortChange = useCallback( | |
| 312 | (value: string) => { | |
| 313 | if (!value) return; | |
| 314 | const nextOption = effortLevels.find((option) => option.value === value); | |
| 315 | if (!nextOption) return; | |
| 316 | if (provider === "opencode") { | |
| 317 | updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); | |
| 318 | return; | |
| 319 | } | |
| 320 | if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { | |
| 321 | const nextPrompt = | |
| 322 | prompt.trim().length === 0 | |
| 323 | ? ULTRATHINK_PROMPT_PREFIX | |
| 324 | : applyClaudePromptEffortPrefix(prompt, "ultrathink"); | |
| 325 | onPromptChange(nextPrompt); | |
| 326 | return; | |
| 327 | } | |
| 328 | if (ultrathinkInBodyText) return; | |
| 329 | if (ultrathinkPromptControlled) { | |
| 330 | const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); | |
| 331 | onPromptChange(stripped); | |
| 332 | } | |
| 333 | const effortKey = getEffortKey(provider); | |
| 334 | updateModelOptions( | |
| 335 | buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), | |
| 336 | ); | |
| 337 | }, | |
| 338 | [ | |
| 339 | ultrathinkPromptControlled, | |
| 340 | ultrathinkInBodyText, | |
| 341 | modelOptions, | |
| 342 | onPromptChange, | |
| 343 | updateModelOptions, | |
| 344 | effortLevels, | |
| 345 | prompt, | |
| 346 | caps.promptInjectedEffortLevels, | |
| 347 | provider, | |
| 348 | ], | |
| 349 | ); | |
| 257 | const handleSelectChange = ( | |
| 258 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>, | |
| 259 | value: string, | |
| 260 | ) => { | |
| 261 | if (!value) return; | |
| 262 | if (descriptor.promptInjectedValues?.includes(value)) { | |
| 263 | const nextPrompt = | |
| 264 | prompt.trim().length === 0 | |
| 265 | ? ULTRATHINK_PROMPT_PREFIX | |
| 266 | : applyClaudePromptEffortPrefix(prompt, "ultrathink"); | |
| 267 | onPromptChange(nextPrompt); | |
| 268 | return; | |
| 269 | } | |
| 270 | if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return; | |
| 271 | if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { | |
| 272 | const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); | |
| 273 | onPromptChange(stripped); | |
| 274 | } | |
| 275 | updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); | |
| 276 | }; | |
| 350 | 277 | |
| 351 | 278 | if (!hasAnyControls) { |
| 352 | 279 | return null; |
| ... | ... | @@ -354,121 +281,62 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 354 | 281 | |
| 355 | 282 | return ( |
| 356 | 283 | <> |
| 357 | {showsEffortSection ? ( | |
| 358 | <> | |
| 284 | {selectDescriptors.map((descriptor, index) => ( | |
| 285 | <div key={descriptor.id}> | |
| 286 | {index > 0 ? <MenuDivider /> : null} | |
| 359 | 287 | <MenuGroup> |
| 360 | 288 | <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs"> |
| 361 | {provider === "opencode" ? "Variant" : "Effort"} | |
| 289 | {descriptor.label} | |
| 362 | 290 | </div> |
| 363 | {ultrathinkInBodyText ? ( | |
| 291 | {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? ( | |
| 364 | 292 | <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs"> |
| 365 | Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort. | |
| 293 | Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change this | |
| 294 | option. | |
| 366 | 295 | </div> |
| 367 | 296 | ) : null} |
| 368 | 297 | <MenuRadioGroup |
| 369 | value={ultrathinkPromptControlled ? "ultrathink" : effort} | |
| 370 | onValueChange={handleEffortChange} | |
| 298 | value={ | |
| 299 | ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id | |
| 300 | ? "ultrathink" | |
| 301 | : (getDescriptorStringValue(descriptor) ?? "") | |
| 302 | } | |
| 303 | onValueChange={(value) => handleSelectChange(descriptor, value)} | |
| 371 | 304 | > |
| 372 | {effortLevels.map((option) => ( | |
| 305 | {descriptor.options.map((option) => ( | |
| 373 | 306 | <MenuRadioItem |
| 374 | key={option.value} | |
| 375 | value={option.value} | |
| 376 | disabled={ultrathinkInBodyText} | |
| 307 | key={option.id} | |
| 308 | value={option.id} | |
| 309 | disabled={ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id} | |
| 377 | 310 | > |
| 378 | 311 | {option.label} |
| 379 | {(provider === "opencode" ? option.isDefault : option.value === defaultEffort) | |
| 380 | ? " (default)" | |
| 381 | : ""} | |
| 312 | {option.isDefault ? " (default)" : ""} | |
| 382 | 313 | </MenuRadioItem> |
| 383 | 314 | ))} |
| 384 | 315 | </MenuRadioGroup> |
| 385 | 316 | </MenuGroup> |
| 386 | </> | |
| 387 | ) : showsThinkingSection ? ( | |
| 388 | <MenuGroup> | |
| 389 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div> | |
| 390 | <MenuRadioGroup | |
| 391 | value={thinkingEnabled ? "on" : "off"} | |
| 392 | onValueChange={(value) => { | |
| 393 | updateModelOptions( | |
| 394 | buildNextOptions(provider, modelOptions, { thinking: value === "on" }), | |
| 395 | ); | |
| 396 | }} | |
| 397 | > | |
| 398 | <MenuRadioItem value="on">On (default)</MenuRadioItem> | |
| 399 | <MenuRadioItem value="off">Off</MenuRadioItem> | |
| 400 | </MenuRadioGroup> | |
| 401 | </MenuGroup> | |
| 402 | ) : null} | |
| 403 | {showsFastModeSection ? ( | |
| 404 | <> | |
| 405 | {showsEffortSection || showsThinkingSection ? <MenuDivider /> : null} | |
| 406 | <MenuGroup> | |
| 407 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 408 | <MenuRadioGroup | |
| 409 | value={fastModeEnabled ? "on" : "off"} | |
| 410 | onValueChange={(value) => { | |
| 411 | updateModelOptions( | |
| 412 | buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), | |
| 413 | ); | |
| 414 | }} | |
| 415 | > | |
| 416 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 417 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 418 | </MenuRadioGroup> | |
| 419 | </MenuGroup> | |
| 420 | </> | |
| 421 | ) : null} | |
| 422 | {showsContextWindowSection ? ( | |
| 423 | <> | |
| 424 | {showsEffortSection || showsThinkingSection || showsFastModeSection ? ( | |
| 425 | <MenuDivider /> | |
| 426 | ) : null} | |
| 317 | </div> | |
| 318 | ))} | |
| 319 | {booleanDescriptors.map((descriptor, index) => ( | |
| 320 | <div key={descriptor.id}> | |
| 321 | {index > 0 || selectDescriptors.length > 0 ? <MenuDivider /> : null} | |
| 427 | 322 | <MenuGroup> |
| 428 | 323 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs"> |
| 429 | Context Window | |
| 324 | {descriptor.label} | |
| 430 | 325 | </div> |
| 431 | 326 | <MenuRadioGroup |
| 432 | value={contextWindow ?? defaultContextWindow ?? ""} | |
| 327 | value={descriptor.currentValue === true ? "on" : "off"} | |
| 433 | 328 | onValueChange={(value) => { |
| 434 | updateModelOptions( | |
| 435 | buildNextOptions(provider, modelOptions, { | |
| 436 | contextWindow: value, | |
| 437 | }), | |
| 329 | updateDescriptors( | |
| 330 | replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), | |
| 438 | 331 | ); |
| 439 | 332 | }} |
| 440 | 333 | > |
| 441 | {contextWindowOptions.map((option) => ( | |
| 442 | <MenuRadioItem key={option.value} value={option.value}> | |
| 443 | {option.label} | |
| 444 | {option.value === defaultContextWindow ? " (default)" : ""} | |
| 445 | </MenuRadioItem> | |
| 446 | ))} | |
| 447 | </MenuRadioGroup> | |
| 448 | </MenuGroup> | |
| 449 | </> | |
| 450 | ) : null} | |
| 451 | {agentOptions.length > 0 ? ( | |
| 452 | <> | |
| 453 | {hasSectionsBeforeAgent ? <MenuDivider /> : null} | |
| 454 | <MenuGroup> | |
| 455 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div> | |
| 456 | <MenuRadioGroup | |
| 457 | value={selectedAgent ?? ""} | |
| 458 | onValueChange={(value) => { | |
| 459 | updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value })); | |
| 460 | }} | |
| 461 | > | |
| 462 | {agentOptions.map((option) => ( | |
| 463 | <MenuRadioItem key={option.value} value={option.value}> | |
| 464 | {option.label} | |
| 465 | {option.isDefault ? " (default)" : ""} | |
| 466 | </MenuRadioItem> | |
| 467 | ))} | |
| 334 | <MenuRadioItem value="on">On</MenuRadioItem> | |
| 335 | <MenuRadioItem value="off">Off</MenuRadioItem> | |
| 468 | 336 | </MenuRadioGroup> |
| 469 | 337 | </MenuGroup> |
| 470 | </> | |
| 471 | ) : null} | |
| 338 | </div> | |
| 339 | ))} | |
| 472 | 340 | </> |
| 473 | 341 | ); |
| 474 | 342 | }); |
| ... | ... | @@ -486,52 +354,15 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 486 | 354 | ...persistence |
| 487 | 355 | }: TraitsMenuContentProps & TraitsPersistence) { |
| 488 | 356 | const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 489 | const { | |
| 490 | caps, | |
| 491 | effort, | |
| 492 | effortLevels, | |
| 493 | thinkingEnabled, | |
| 494 | fastModeEnabled, | |
| 495 | contextWindowOptions, | |
| 496 | contextWindow, | |
| 497 | defaultContextWindow, | |
| 498 | ultrathinkPromptControlled, | |
| 499 | showEffort, | |
| 500 | showThinking, | |
| 501 | showContextWindow, | |
| 502 | } = getTraitsSectionVisibility({ | |
| 503 | provider, | |
| 504 | models, | |
| 505 | model, | |
| 506 | prompt, | |
| 507 | modelOptions, | |
| 508 | allowPromptInjectedEffort, | |
| 509 | }); | |
| 510 | const { selectedAgentLabel } = getSelectedTraits( | |
| 511 | provider, | |
| 512 | models, | |
| 513 | model, | |
| 514 | prompt, | |
| 515 | modelOptions, | |
| 516 | allowPromptInjectedEffort, | |
| 517 | ); | |
| 518 | ||
| 519 | const effortLabel = effort | |
| 520 | ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) | |
| 521 | : null; | |
| 522 | const primaryTraitLabel = ultrathinkPromptControlled | |
| 523 | ? "Ultrathink" | |
| 524 | : effortLabel | |
| 525 | ? effortLabel | |
| 526 | : thinkingEnabled === null | |
| 527 | ? null | |
| 528 | : `Thinking ${thinkingEnabled ? "On" : "Off"}`; | |
| 529 | const contextWindowLabel = | |
| 530 | showContextWindow && contextWindow !== defaultContextWindow | |
| 531 | ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) | |
| 532 | : null; | |
| 533 | const fastOnlyControl = | |
| 534 | caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow; | |
| 357 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = | |
| 358 | getTraitsSectionVisibility({ | |
| 359 | provider, | |
| 360 | models, | |
| 361 | model, | |
| 362 | prompt, | |
| 363 | modelOptions, | |
| 364 | allowPromptInjectedEffort, | |
| 365 | }); | |
| 535 | 366 | if ( |
| 536 | 367 | !shouldRenderTraitsControls({ |
| 537 | 368 | provider, |
| ... | ... | @@ -545,27 +376,22 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 545 | 376 | return null; |
| 546 | 377 | } |
| 547 | 378 | |
| 548 | const selectedTriggerTraits = [ | |
| 549 | primaryTraitLabel, | |
| 550 | ...(caps.supportsFastMode && | |
| 551 | (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null)) | |
| 552 | ? [fastModeEnabled ? "Fast" : "Normal"] | |
| 553 | : []), | |
| 554 | ...(contextWindowLabel ? [contextWindowLabel] : []), | |
| 555 | ...(selectedAgentLabel ? [selectedAgentLabel] : []), | |
| 556 | ].filter(Boolean); | |
| 557 | const triggerLabel = fastOnlyControl | |
| 558 | ? fastModeEnabled | |
| 559 | ? "Fast" | |
| 560 | : "Normal" | |
| 561 | : selectedTriggerTraits.length > 0 | |
| 562 | ? selectedTriggerTraits.join(" · ") | |
| 563 | : caps.supportsFastMode | |
| 564 | ? "Normal" | |
| 565 | : defaultContextWindow | |
| 566 | ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ?? | |
| 567 | defaultContextWindow) | |
| 568 | : (selectedAgentLabel ?? ""); | |
| 379 | const triggerLabel = | |
| 380 | descriptors | |
| 381 | .map((descriptor) => { | |
| 382 | if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { | |
| 383 | return "Ultrathink"; | |
| 384 | } | |
| 385 | if (descriptor.type === "boolean") { | |
| 386 | if (descriptor.id === "fastMode") { | |
| 387 | return descriptor.currentValue === true ? "Fast" : "Normal"; | |
| 388 | } | |
| 389 | return `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`; | |
| 390 | } | |
| 391 | return getProviderOptionCurrentLabel(descriptor); | |
| 392 | }) | |
| 393 | .filter((label): label is string => typeof label === "string" && label.length > 0) | |
| 394 | .join(" · ") || ""; | |
| 569 | 395 | |
| 570 | 396 | const isCodexStyle = provider === "codex"; |
| 571 | 397 |
apps/web/src/components/chat/composerProviderRegistry.test.tsx deleted-516| ... | ... | @@ -1,516 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | getComposerProviderControls, | |
| 5 | getComposerProviderState, | |
| 6 | renderProviderTraitsMenuContent, | |
| 7 | renderProviderTraitsPicker, | |
| 8 | } from "./composerProviderRegistry"; | |
| 9 | ||
| 10 | const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 11 | { | |
| 12 | slug: "gpt-5.4", | |
| 13 | name: "GPT-5.4", | |
| 14 | isCustom: false, | |
| 15 | capabilities: { | |
| 16 | reasoningEffortLevels: [ | |
| 17 | { value: "xhigh", label: "Extra High" }, | |
| 18 | { value: "high", label: "High", isDefault: true }, | |
| 19 | { value: "medium", label: "Medium" }, | |
| 20 | { value: "low", label: "Low" }, | |
| 21 | ], | |
| 22 | supportsFastMode: true, | |
| 23 | supportsThinkingToggle: false, | |
| 24 | contextWindowOptions: [], | |
| 25 | promptInjectedEffortLevels: [], | |
| 26 | }, | |
| 27 | }, | |
| 28 | ]; | |
| 29 | ||
| 30 | const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 31 | { | |
| 32 | slug: "claude-opus-4-6", | |
| 33 | name: "Claude Opus 4.6", | |
| 34 | isCustom: false, | |
| 35 | capabilities: { | |
| 36 | reasoningEffortLevels: [ | |
| 37 | { value: "medium", label: "Medium" }, | |
| 38 | { value: "high", label: "High", isDefault: true }, | |
| 39 | { value: "max", label: "Max" }, | |
| 40 | { value: "ultrathink", label: "Ultrathink" }, | |
| 41 | ], | |
| 42 | supportsFastMode: true, | |
| 43 | supportsThinkingToggle: false, | |
| 44 | contextWindowOptions: [], | |
| 45 | promptInjectedEffortLevels: ["ultrathink"], | |
| 46 | }, | |
| 47 | }, | |
| 48 | { | |
| 49 | slug: "claude-sonnet-4-6", | |
| 50 | name: "Claude Sonnet 4.6", | |
| 51 | isCustom: false, | |
| 52 | capabilities: { | |
| 53 | reasoningEffortLevels: [ | |
| 54 | { value: "low", label: "Low" }, | |
| 55 | { value: "medium", label: "Medium" }, | |
| 56 | { value: "high", label: "High", isDefault: true }, | |
| 57 | { value: "ultrathink", label: "Ultrathink" }, | |
| 58 | ], | |
| 59 | supportsFastMode: false, | |
| 60 | supportsThinkingToggle: false, | |
| 61 | contextWindowOptions: [], | |
| 62 | promptInjectedEffortLevels: ["ultrathink"], | |
| 63 | }, | |
| 64 | }, | |
| 65 | { | |
| 66 | slug: "claude-haiku-4-5", | |
| 67 | name: "Claude Haiku 4.5", | |
| 68 | isCustom: false, | |
| 69 | capabilities: { | |
| 70 | reasoningEffortLevels: [], | |
| 71 | supportsFastMode: false, | |
| 72 | supportsThinkingToggle: true, | |
| 73 | contextWindowOptions: [], | |
| 74 | promptInjectedEffortLevels: [], | |
| 75 | }, | |
| 76 | }, | |
| 77 | ]; | |
| 78 | ||
| 79 | const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [ | |
| 80 | { | |
| 81 | slug: "claude-opus-4-6", | |
| 82 | name: "Claude Opus 4.6", | |
| 83 | isCustom: false, | |
| 84 | capabilities: { | |
| 85 | reasoningEffortLevels: [ | |
| 86 | { value: "medium", label: "Medium" }, | |
| 87 | { value: "high", label: "High", isDefault: true }, | |
| 88 | { value: "max", label: "Max" }, | |
| 89 | { value: "ultrathink", label: "Ultrathink" }, | |
| 90 | ], | |
| 91 | supportsFastMode: true, | |
| 92 | supportsThinkingToggle: false, | |
| 93 | contextWindowOptions: [ | |
| 94 | { value: "200k", label: "200k", isDefault: true }, | |
| 95 | { value: "1m", label: "1M" }, | |
| 96 | ], | |
| 97 | promptInjectedEffortLevels: ["ultrathink"], | |
| 98 | }, | |
| 99 | }, | |
| 100 | { | |
| 101 | slug: "claude-haiku-4-5", | |
| 102 | name: "Claude Haiku 4.5", | |
| 103 | isCustom: false, | |
| 104 | capabilities: { | |
| 105 | reasoningEffortLevels: [], | |
| 106 | supportsFastMode: false, | |
| 107 | supportsThinkingToggle: true, | |
| 108 | contextWindowOptions: [], | |
| 109 | promptInjectedEffortLevels: [], | |
| 110 | }, | |
| 111 | }, | |
| 112 | ]; | |
| 113 | ||
| 114 | const OPENCODE_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 115 | { | |
| 116 | slug: "openai/gpt-5", | |
| 117 | name: "GPT-5", | |
| 118 | isCustom: false, | |
| 119 | capabilities: { | |
| 120 | reasoningEffortLevels: [], | |
| 121 | supportsFastMode: false, | |
| 122 | supportsThinkingToggle: false, | |
| 123 | contextWindowOptions: [], | |
| 124 | promptInjectedEffortLevels: [], | |
| 125 | variantOptions: [ | |
| 126 | { value: "low", label: "Low" }, | |
| 127 | { value: "medium", label: "Medium", isDefault: true }, | |
| 128 | ], | |
| 129 | agentOptions: [ | |
| 130 | { value: "build", label: "Build", isDefault: true }, | |
| 131 | { value: "plan", label: "Plan" }, | |
| 132 | ], | |
| 133 | }, | |
| 134 | }, | |
| 135 | ]; | |
| 136 | ||
| 137 | describe("getComposerProviderState", () => { | |
| 138 | it("returns codex defaults when no codex draft options exist", () => { | |
| 139 | const state = getComposerProviderState({ | |
| 140 | provider: "codex", | |
| 141 | model: "gpt-5.4", | |
| 142 | models: CODEX_MODELS, | |
| 143 | prompt: "", | |
| 144 | modelOptions: undefined, | |
| 145 | }); | |
| 146 | ||
| 147 | expect(state).toEqual({ | |
| 148 | provider: "codex", | |
| 149 | promptEffort: "high", | |
| 150 | modelOptionsForDispatch: { | |
| 151 | reasoningEffort: "high", | |
| 152 | }, | |
| 153 | }); | |
| 154 | }); | |
| 155 | ||
| 156 | it("normalizes codex dispatch options while preserving the selected effort", () => { | |
| 157 | const state = getComposerProviderState({ | |
| 158 | provider: "codex", | |
| 159 | model: "gpt-5.4", | |
| 160 | models: CODEX_MODELS, | |
| 161 | prompt: "", | |
| 162 | modelOptions: { | |
| 163 | codex: { | |
| 164 | reasoningEffort: "low", | |
| 165 | fastMode: true, | |
| 166 | }, | |
| 167 | }, | |
| 168 | }); | |
| 169 | ||
| 170 | expect(state).toEqual({ | |
| 171 | provider: "codex", | |
| 172 | promptEffort: "low", | |
| 173 | modelOptionsForDispatch: { | |
| 174 | reasoningEffort: "low", | |
| 175 | fastMode: true, | |
| 176 | }, | |
| 177 | }); | |
| 178 | }); | |
| 179 | ||
| 180 | it("preserves codex fast mode when it is the only active option", () => { | |
| 181 | const state = getComposerProviderState({ | |
| 182 | provider: "codex", | |
| 183 | model: "gpt-5.4", | |
| 184 | models: CODEX_MODELS, | |
| 185 | prompt: "", | |
| 186 | modelOptions: { | |
| 187 | codex: { | |
| 188 | fastMode: true, | |
| 189 | }, | |
| 190 | }, | |
| 191 | }); | |
| 192 | ||
| 193 | expect(state).toEqual({ | |
| 194 | provider: "codex", | |
| 195 | promptEffort: "high", | |
| 196 | modelOptionsForDispatch: { | |
| 197 | reasoningEffort: "high", | |
| 198 | fastMode: true, | |
| 199 | }, | |
| 200 | }); | |
| 201 | }); | |
| 202 | ||
| 203 | it("preserves codex default effort explicitly in dispatch options", () => { | |
| 204 | const state = getComposerProviderState({ | |
| 205 | provider: "codex", | |
| 206 | model: "gpt-5.4", | |
| 207 | models: CODEX_MODELS, | |
| 208 | prompt: "", | |
| 209 | modelOptions: { | |
| 210 | codex: { | |
| 211 | reasoningEffort: "high", | |
| 212 | fastMode: false, | |
| 213 | }, | |
| 214 | }, | |
| 215 | }); | |
| 216 | ||
| 217 | expect(state).toEqual({ | |
| 218 | provider: "codex", | |
| 219 | promptEffort: "high", | |
| 220 | modelOptionsForDispatch: { | |
| 221 | reasoningEffort: "high", | |
| 222 | fastMode: false, | |
| 223 | }, | |
| 224 | }); | |
| 225 | }); | |
| 226 | ||
| 227 | it("returns Claude defaults for effort-capable models", () => { | |
| 228 | const state = getComposerProviderState({ | |
| 229 | provider: "claudeAgent", | |
| 230 | model: "claude-sonnet-4-6", | |
| 231 | models: CLAUDE_MODELS, | |
| 232 | prompt: "", | |
| 233 | modelOptions: undefined, | |
| 234 | }); | |
| 235 | ||
| 236 | expect(state).toEqual({ | |
| 237 | provider: "claudeAgent", | |
| 238 | promptEffort: "high", | |
| 239 | modelOptionsForDispatch: { | |
| 240 | effort: "high", | |
| 241 | }, | |
| 242 | }); | |
| 243 | }); | |
| 244 | ||
| 245 | it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { | |
| 246 | const state = getComposerProviderState({ | |
| 247 | provider: "claudeAgent", | |
| 248 | model: "claude-sonnet-4-6", | |
| 249 | models: CLAUDE_MODELS, | |
| 250 | prompt: "Ultrathink:\nInvestigate this failure", | |
| 251 | modelOptions: { | |
| 252 | claudeAgent: { | |
| 253 | effort: "medium", | |
| 254 | }, | |
| 255 | }, | |
| 256 | }); | |
| 257 | ||
| 258 | expect(state).toEqual({ | |
| 259 | provider: "claudeAgent", | |
| 260 | promptEffort: "medium", | |
| 261 | modelOptionsForDispatch: { | |
| 262 | effort: "medium", | |
| 263 | }, | |
| 264 | composerFrameClassName: "ultrathink-frame", | |
| 265 | composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", | |
| 266 | modelPickerIconClassName: "ultrathink-chroma", | |
| 267 | }); | |
| 268 | }); | |
| 269 | ||
| 270 | it("drops unsupported Claude effort options for models without effort controls", () => { | |
| 271 | const state = getComposerProviderState({ | |
| 272 | provider: "claudeAgent", | |
| 273 | model: "claude-haiku-4-5", | |
| 274 | models: CLAUDE_MODELS, | |
| 275 | prompt: "", | |
| 276 | modelOptions: { | |
| 277 | claudeAgent: { | |
| 278 | effort: "max", | |
| 279 | thinking: false, | |
| 280 | }, | |
| 281 | }, | |
| 282 | }); | |
| 283 | ||
| 284 | expect(state).toEqual({ | |
| 285 | provider: "claudeAgent", | |
| 286 | promptEffort: null, | |
| 287 | modelOptionsForDispatch: { | |
| 288 | thinking: false, | |
| 289 | }, | |
| 290 | }); | |
| 291 | }); | |
| 292 | ||
| 293 | it("preserves Claude fast mode when it is the only active option", () => { | |
| 294 | const state = getComposerProviderState({ | |
| 295 | provider: "claudeAgent", | |
| 296 | model: "claude-opus-4-6", | |
| 297 | models: CLAUDE_MODELS, | |
| 298 | prompt: "", | |
| 299 | modelOptions: { | |
| 300 | claudeAgent: { | |
| 301 | fastMode: true, | |
| 302 | }, | |
| 303 | }, | |
| 304 | }); | |
| 305 | ||
| 306 | expect(state).toEqual({ | |
| 307 | provider: "claudeAgent", | |
| 308 | promptEffort: "high", | |
| 309 | modelOptionsForDispatch: { | |
| 310 | effort: "high", | |
| 311 | fastMode: true, | |
| 312 | }, | |
| 313 | }); | |
| 314 | }); | |
| 315 | ||
| 316 | it("preserves Claude default effort explicitly in dispatch options", () => { | |
| 317 | const state = getComposerProviderState({ | |
| 318 | provider: "claudeAgent", | |
| 319 | model: "claude-opus-4-6", | |
| 320 | models: CLAUDE_MODELS, | |
| 321 | prompt: "", | |
| 322 | modelOptions: { | |
| 323 | claudeAgent: { | |
| 324 | effort: "high", | |
| 325 | fastMode: false, | |
| 326 | }, | |
| 327 | }, | |
| 328 | }); | |
| 329 | ||
| 330 | expect(state).toEqual({ | |
| 331 | provider: "claudeAgent", | |
| 332 | promptEffort: "high", | |
| 333 | modelOptionsForDispatch: { | |
| 334 | effort: "high", | |
| 335 | fastMode: false, | |
| 336 | }, | |
| 337 | }); | |
| 338 | }); | |
| 339 | ||
| 340 | it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => { | |
| 341 | // Regression: normalizeClaudeModelOptionsWithCapabilities used to strip | |
| 342 | // fastMode: false, which meant deepMerge could never clear a previous true. | |
| 343 | const state = getComposerProviderState({ | |
| 344 | provider: "claudeAgent", | |
| 345 | model: "claude-opus-4-6", | |
| 346 | models: CLAUDE_MODELS, | |
| 347 | prompt: "", | |
| 348 | modelOptions: { | |
| 349 | claudeAgent: { | |
| 350 | effort: "high", | |
| 351 | fastMode: false, | |
| 352 | }, | |
| 353 | }, | |
| 354 | }); | |
| 355 | ||
| 356 | expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); | |
| 357 | }); | |
| 358 | ||
| 359 | it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => { | |
| 360 | // Regression: thinking: true (the default) used to be stripped, which | |
| 361 | // meant deepMerge could never clear a previous thinking: false. | |
| 362 | const state = getComposerProviderState({ | |
| 363 | provider: "claudeAgent", | |
| 364 | model: "claude-haiku-4-5", | |
| 365 | models: CLAUDE_MODELS, | |
| 366 | prompt: "", | |
| 367 | modelOptions: { | |
| 368 | claudeAgent: { | |
| 369 | thinking: true, | |
| 370 | }, | |
| 371 | }, | |
| 372 | }); | |
| 373 | ||
| 374 | expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); | |
| 375 | }); | |
| 376 | ||
| 377 | it("preserves Claude default context window explicitly in dispatch options", () => { | |
| 378 | const state = getComposerProviderState({ | |
| 379 | provider: "claudeAgent", | |
| 380 | model: "claude-opus-4-6", | |
| 381 | models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, | |
| 382 | prompt: "", | |
| 383 | modelOptions: { | |
| 384 | claudeAgent: { | |
| 385 | effort: "high", | |
| 386 | contextWindow: "200k", | |
| 387 | }, | |
| 388 | }, | |
| 389 | }); | |
| 390 | ||
| 391 | expect(state.modelOptionsForDispatch).toMatchObject({ | |
| 392 | effort: "high", | |
| 393 | contextWindow: "200k", | |
| 394 | }); | |
| 395 | }); | |
| 396 | ||
| 397 | it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => { | |
| 398 | // Regression: the default contextWindow must survive normalization so | |
| 399 | // deepMerge can clear an older non-default 1m selection. | |
| 400 | const state = getComposerProviderState({ | |
| 401 | provider: "claudeAgent", | |
| 402 | model: "claude-opus-4-6", | |
| 403 | models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, | |
| 404 | prompt: "", | |
| 405 | modelOptions: { | |
| 406 | claudeAgent: { | |
| 407 | contextWindow: "200k", | |
| 408 | }, | |
| 409 | }, | |
| 410 | }); | |
| 411 | ||
| 412 | expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k"); | |
| 413 | }); | |
| 414 | ||
| 415 | it("omits contextWindow when the model does not support it", () => { | |
| 416 | const state = getComposerProviderState({ | |
| 417 | provider: "claudeAgent", | |
| 418 | model: "claude-haiku-4-5", | |
| 419 | models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, | |
| 420 | prompt: "", | |
| 421 | modelOptions: { | |
| 422 | claudeAgent: { | |
| 423 | contextWindow: "1m", | |
| 424 | }, | |
| 425 | }, | |
| 426 | }); | |
| 427 | ||
| 428 | expect(state.modelOptionsForDispatch).toBeUndefined(); | |
| 429 | }); | |
| 430 | ||
| 431 | it("omits fastMode when the model does not support it", () => { | |
| 432 | const state = getComposerProviderState({ | |
| 433 | provider: "claudeAgent", | |
| 434 | model: "claude-sonnet-4-6", | |
| 435 | models: CLAUDE_MODELS, | |
| 436 | prompt: "", | |
| 437 | modelOptions: { | |
| 438 | claudeAgent: { | |
| 439 | effort: "high", | |
| 440 | fastMode: true, | |
| 441 | }, | |
| 442 | }, | |
| 443 | }); | |
| 444 | ||
| 445 | expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); | |
| 446 | }); | |
| 447 | ||
| 448 | it("preserves OpenCode variant and agent options for dispatch", () => { | |
| 449 | const state = getComposerProviderState({ | |
| 450 | provider: "opencode", | |
| 451 | model: "openai/gpt-5", | |
| 452 | models: OPENCODE_MODELS, | |
| 453 | prompt: "", | |
| 454 | modelOptions: { | |
| 455 | opencode: { | |
| 456 | variant: "medium", | |
| 457 | agent: "plan", | |
| 458 | }, | |
| 459 | }, | |
| 460 | }); | |
| 461 | ||
| 462 | expect(state).toEqual({ | |
| 463 | provider: "opencode", | |
| 464 | promptEffort: "medium", | |
| 465 | modelOptionsForDispatch: { | |
| 466 | variant: "medium", | |
| 467 | agent: "plan", | |
| 468 | }, | |
| 469 | }); | |
| 470 | }); | |
| 471 | }); | |
| 472 | ||
| 473 | describe("getComposerProviderControls", () => { | |
| 474 | it("hides the interaction mode toggle for OpenCode", () => { | |
| 475 | expect(getComposerProviderControls("opencode")).toEqual({ | |
| 476 | showInteractionModeToggle: false, | |
| 477 | }); | |
| 478 | }); | |
| 479 | ||
| 480 | it("keeps the interaction mode toggle for Codex and Claude", () => { | |
| 481 | expect(getComposerProviderControls("codex")).toEqual({ | |
| 482 | showInteractionModeToggle: true, | |
| 483 | }); | |
| 484 | expect(getComposerProviderControls("claudeAgent")).toEqual({ | |
| 485 | showInteractionModeToggle: true, | |
| 486 | }); | |
| 487 | }); | |
| 488 | }); | |
| 489 | ||
| 490 | describe("provider traits render guards", () => { | |
| 491 | it("returns null for codex traits picker when no thread target is provided", () => { | |
| 492 | const content = renderProviderTraitsPicker({ | |
| 493 | provider: "codex", | |
| 494 | model: "gpt-5.4", | |
| 495 | models: CODEX_MODELS, | |
| 496 | modelOptions: undefined, | |
| 497 | prompt: "", | |
| 498 | onPromptChange: () => {}, | |
| 499 | }); | |
| 500 | ||
| 501 | expect(content).toBeNull(); | |
| 502 | }); | |
| 503 | ||
| 504 | it("returns null for claude traits menu content when no thread target is provided", () => { | |
| 505 | const content = renderProviderTraitsMenuContent({ | |
| 506 | provider: "claudeAgent", | |
| 507 | model: "claude-sonnet-4-6", | |
| 508 | models: CLAUDE_MODELS, | |
| 509 | modelOptions: undefined, | |
| 510 | prompt: "", | |
| 511 | onPromptChange: () => {}, | |
| 512 | }); | |
| 513 | ||
| 514 | expect(content).toBeNull(); | |
| 515 | }); | |
| 516 | }); |
apps/web/src/components/chat/composerProviderRegistry.tsx deleted-216| ... | ... | @@ -1,216 +0,0 @@ |
| 1 | import { | |
| 2 | type ProviderKind, | |
| 3 | type ProviderModelOptions, | |
| 4 | type ScopedThreadRef, | |
| 5 | type ServerProviderModel, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | isClaudeUltrathinkPrompt, | |
| 9 | normalizeProviderModelOptionsWithCapabilities, | |
| 10 | resolveEffort, | |
| 11 | trimOrNull, | |
| 12 | } from "@t3tools/shared/model"; | |
| 13 | import type { ReactNode } from "react"; | |
| 14 | ||
| 15 | import type { DraftId } from "../../composerDraftStore"; | |
| 16 | import { getProviderModelCapabilities } from "../../providerModels"; | |
| 17 | import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | |
| 18 | ||
| 19 | export type ComposerProviderStateInput = { | |
| 20 | provider: ProviderKind; | |
| 21 | model: string; | |
| 22 | models: ReadonlyArray<ServerProviderModel>; | |
| 23 | prompt: string; | |
| 24 | modelOptions: ProviderModelOptions | null | undefined; | |
| 25 | }; | |
| 26 | ||
| 27 | export type ComposerProviderState = { | |
| 28 | provider: ProviderKind; | |
| 29 | promptEffort: string | null; | |
| 30 | modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; | |
| 31 | composerFrameClassName?: string; | |
| 32 | composerSurfaceClassName?: string; | |
| 33 | modelPickerIconClassName?: string; | |
| 34 | }; | |
| 35 | ||
| 36 | type TraitsRenderInput = { | |
| 37 | threadRef?: ScopedThreadRef; | |
| 38 | draftId?: DraftId; | |
| 39 | model: string; | |
| 40 | models: ReadonlyArray<ServerProviderModel>; | |
| 41 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | |
| 42 | prompt: string; | |
| 43 | onPromptChange: (prompt: string) => void; | |
| 44 | }; | |
| 45 | ||
| 46 | export type ComposerProviderControls = { | |
| 47 | showInteractionModeToggle: boolean; | |
| 48 | }; | |
| 49 | ||
| 50 | type ProviderRegistryEntry = { | |
| 51 | controls: ComposerProviderControls; | |
| 52 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; | |
| 53 | renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode; | |
| 54 | renderTraitsPicker: (input: TraitsRenderInput) => ReactNode; | |
| 55 | }; | |
| 56 | ||
| 57 | function hasComposerTraitsTarget(input: { | |
| 58 | threadRef: ScopedThreadRef | undefined; | |
| 59 | draftId: DraftId | undefined; | |
| 60 | }): boolean { | |
| 61 | return input.threadRef !== undefined || input.draftId !== undefined; | |
| 62 | } | |
| 63 | ||
| 64 | function renderTraitsControl( | |
| 65 | Component: typeof TraitsMenuContent | typeof TraitsPicker, | |
| 66 | provider: ProviderKind, | |
| 67 | input: TraitsRenderInput, | |
| 68 | ): ReactNode { | |
| 69 | const { threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = input; | |
| 70 | if ( | |
| 71 | !hasComposerTraitsTarget({ threadRef, draftId }) || | |
| 72 | !shouldRenderTraitsControls({ | |
| 73 | provider, | |
| 74 | models, | |
| 75 | model, | |
| 76 | modelOptions, | |
| 77 | prompt, | |
| 78 | }) | |
| 79 | ) { | |
| 80 | return null; | |
| 81 | } | |
| 82 | ||
| 83 | return ( | |
| 84 | <Component | |
| 85 | provider={provider} | |
| 86 | models={models} | |
| 87 | {...(threadRef ? { threadRef } : {})} | |
| 88 | {...(draftId ? { draftId } : {})} | |
| 89 | model={model} | |
| 90 | modelOptions={modelOptions} | |
| 91 | prompt={prompt} | |
| 92 | onPromptChange={onPromptChange} | |
| 93 | /> | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | function getProviderStateFromCapabilities( | |
| 98 | input: ComposerProviderStateInput, | |
| 99 | ): ComposerProviderState { | |
| 100 | const { provider, model, models, prompt, modelOptions } = input; | |
| 101 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 102 | const providerOptions = modelOptions?.[provider]; | |
| 103 | const rawEffort = providerOptions | |
| 104 | ? "effort" in providerOptions | |
| 105 | ? providerOptions.effort | |
| 106 | : "reasoningEffort" in providerOptions | |
| 107 | ? providerOptions.reasoningEffort | |
| 108 | : "reasoning" in providerOptions | |
| 109 | ? providerOptions.reasoning | |
| 110 | : "variant" in providerOptions | |
| 111 | ? providerOptions.variant | |
| 112 | : null | |
| 113 | : null; | |
| 114 | const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( | |
| 115 | provider, | |
| 116 | caps, | |
| 117 | providerOptions, | |
| 118 | ); | |
| 119 | const promptEffort = | |
| 120 | provider === "opencode" | |
| 121 | ? (trimOrNull( | |
| 122 | normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, | |
| 123 | ) ?? null) | |
| 124 | : (resolveEffort(caps, rawEffort) ?? null); | |
| 125 | const ultrathinkActive = | |
| 126 | caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); | |
| 127 | ||
| 128 | return { | |
| 129 | provider, | |
| 130 | promptEffort, | |
| 131 | modelOptionsForDispatch: normalizedOptions, | |
| 132 | ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), | |
| 133 | ...(ultrathinkActive | |
| 134 | ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } | |
| 135 | : {}), | |
| 136 | ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), | |
| 137 | }; | |
| 138 | } | |
| 139 | ||
| 140 | const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = { | |
| 141 | showInteractionModeToggle: true, | |
| 142 | }; | |
| 143 | ||
| 144 | function createProviderRegistryEntry( | |
| 145 | provider: ProviderKind, | |
| 146 | controls?: Partial<ComposerProviderControls>, | |
| 147 | ): ProviderRegistryEntry { | |
| 148 | return { | |
| 149 | controls: { | |
| 150 | ...DEFAULT_PROVIDER_CONTROLS, | |
| 151 | ...controls, | |
| 152 | }, | |
| 153 | getState: (input) => getProviderStateFromCapabilities(input), | |
| 154 | renderTraitsMenuContent: (input) => renderTraitsControl(TraitsMenuContent, provider, input), | |
| 155 | renderTraitsPicker: (input) => renderTraitsControl(TraitsPicker, provider, input), | |
| 156 | }; | |
| 157 | } | |
| 158 | ||
| 159 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | |
| 160 | codex: createProviderRegistryEntry("codex"), | |
| 161 | claudeAgent: createProviderRegistryEntry("claudeAgent"), | |
| 162 | cursor: createProviderRegistryEntry("cursor"), | |
| 163 | opencode: createProviderRegistryEntry("opencode", { | |
| 164 | showInteractionModeToggle: false, | |
| 165 | }), | |
| 166 | }; | |
| 167 | ||
| 168 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { | |
| 169 | return composerProviderRegistry[input.provider].getState(input); | |
| 170 | } | |
| 171 | ||
| 172 | export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls { | |
| 173 | return composerProviderRegistry[provider].controls; | |
| 174 | } | |
| 175 | ||
| 176 | export function renderProviderTraitsMenuContent(input: { | |
| 177 | provider: ProviderKind; | |
| 178 | threadRef?: ScopedThreadRef; | |
| 179 | draftId?: DraftId; | |
| 180 | model: string; | |
| 181 | models: ReadonlyArray<ServerProviderModel>; | |
| 182 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | |
| 183 | prompt: string; | |
| 184 | onPromptChange: (prompt: string) => void; | |
| 185 | }): ReactNode { | |
| 186 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ | |
| 187 | ...(input.threadRef ? { threadRef: input.threadRef } : {}), | |
| 188 | ...(input.draftId ? { draftId: input.draftId } : {}), | |
| 189 | model: input.model, | |
| 190 | models: input.models, | |
| 191 | modelOptions: input.modelOptions, | |
| 192 | prompt: input.prompt, | |
| 193 | onPromptChange: input.onPromptChange, | |
| 194 | }); | |
| 195 | } | |
| 196 | ||
| 197 | export function renderProviderTraitsPicker(input: { | |
| 198 | provider: ProviderKind; | |
| 199 | threadRef?: ScopedThreadRef; | |
| 200 | draftId?: DraftId; | |
| 201 | model: string; | |
| 202 | models: ReadonlyArray<ServerProviderModel>; | |
| 203 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | |
| 204 | prompt: string; | |
| 205 | onPromptChange: (prompt: string) => void; | |
| 206 | }): ReactNode { | |
| 207 | return composerProviderRegistry[input.provider].renderTraitsPicker({ | |
| 208 | ...(input.threadRef ? { threadRef: input.threadRef } : {}), | |
| 209 | ...(input.draftId ? { draftId: input.draftId } : {}), | |
| 210 | model: input.model, | |
| 211 | models: input.models, | |
| 212 | modelOptions: input.modelOptions, | |
| 213 | prompt: input.prompt, | |
| 214 | onPromptChange: input.onPromptChange, | |
| 215 | }); | |
| 216 | } |
apps/web/src/components/chat/composerProviderState.test.tsx created+242| ... | ... | @@ -0,0 +1,242 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import type { | |
| 3 | ProviderKind, | |
| 4 | ProviderOptionDescriptor, | |
| 5 | ProviderOptionSelection, | |
| 6 | ServerProviderModel, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { | |
| 9 | getComposerProviderState, | |
| 10 | renderProviderTraitsMenuContent, | |
| 11 | renderProviderTraitsPicker, | |
| 12 | } from "./composerProviderState"; | |
| 13 | ||
| 14 | // Everything in composerProviderState is now data-driven by the model's | |
| 15 | // optionDescriptors, so these tests use a single synthetic provider/model and | |
| 16 | // vary only the descriptor shape per scenario. | |
| 17 | ||
| 18 | const PROVIDER: ProviderKind = "codex"; | |
| 19 | const MODEL = "test-model"; | |
| 20 | ||
| 21 | function selectDescriptor( | |
| 22 | id: string, | |
| 23 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 24 | promptInjectedValues?: ReadonlyArray<string>, | |
| 25 | ): Extract<ProviderOptionDescriptor, { type: "select" }> { | |
| 26 | const defaultId = options.find((option) => option.isDefault)?.id; | |
| 27 | return { | |
| 28 | id, | |
| 29 | label: id, | |
| 30 | type: "select", | |
| 31 | options: [...options], | |
| 32 | ...(defaultId ? { currentValue: defaultId } : {}), | |
| 33 | ...(promptInjectedValues && promptInjectedValues.length > 0 | |
| 34 | ? { promptInjectedValues: [...promptInjectedValues] } | |
| 35 | : {}), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> { | |
| 40 | return { id, label: id, type: "boolean" }; | |
| 41 | } | |
| 42 | ||
| 43 | function modelWith( | |
| 44 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | |
| 45 | ): ReadonlyArray<ServerProviderModel> { | |
| 46 | return [ | |
| 47 | { slug: MODEL, name: MODEL, isCustom: false, capabilities: { optionDescriptors: descriptors } }, | |
| 48 | ]; | |
| 49 | } | |
| 50 | ||
| 51 | function selections( | |
| 52 | ...entries: Array<[string, string | boolean]> | |
| 53 | ): ReadonlyArray<ProviderOptionSelection> { | |
| 54 | return entries.map(([id, value]) => ({ id, value })); | |
| 55 | } | |
| 56 | ||
| 57 | const ULTRATHINK_FRAME_CLASSES = { | |
| 58 | composerFrameClassName: "ultrathink-frame", | |
| 59 | composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", | |
| 60 | modelPickerIconClassName: "ultrathink-chroma", | |
| 61 | } as const; | |
| 62 | ||
| 63 | describe("getComposerProviderState", () => { | |
| 64 | it("returns descriptor defaults when no selections are provided", () => { | |
| 65 | const state = getComposerProviderState({ | |
| 66 | provider: PROVIDER, | |
| 67 | model: MODEL, | |
| 68 | models: modelWith([ | |
| 69 | selectDescriptor("effort", [ | |
| 70 | { id: "low", label: "Low" }, | |
| 71 | { id: "high", label: "High", isDefault: true }, | |
| 72 | ]), | |
| 73 | ]), | |
| 74 | prompt: "", | |
| 75 | modelOptions: undefined, | |
| 76 | }); | |
| 77 | ||
| 78 | expect(state).toEqual({ | |
| 79 | provider: PROVIDER, | |
| 80 | promptEffort: "high", | |
| 81 | modelOptionsForDispatch: selections(["effort", "high"]), | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | it("lets selections override defaults and propagates them through dispatch", () => { | |
| 86 | const state = getComposerProviderState({ | |
| 87 | provider: PROVIDER, | |
| 88 | model: MODEL, | |
| 89 | models: modelWith([ | |
| 90 | selectDescriptor("effort", [ | |
| 91 | { id: "low", label: "Low" }, | |
| 92 | { id: "high", label: "High", isDefault: true }, | |
| 93 | ]), | |
| 94 | booleanDescriptor("fastMode"), | |
| 95 | ]), | |
| 96 | prompt: "", | |
| 97 | modelOptions: selections(["effort", "low"], ["fastMode", true]), | |
| 98 | }); | |
| 99 | ||
| 100 | expect(state).toEqual({ | |
| 101 | provider: PROVIDER, | |
| 102 | promptEffort: "low", | |
| 103 | modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | it("preserves selections that match defaults so deepMerge can overwrite prior state", () => { | |
| 108 | const state = getComposerProviderState({ | |
| 109 | provider: PROVIDER, | |
| 110 | model: MODEL, | |
| 111 | models: modelWith([ | |
| 112 | selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), | |
| 113 | booleanDescriptor("fastMode"), | |
| 114 | ]), | |
| 115 | prompt: "", | |
| 116 | modelOptions: selections(["effort", "high"], ["fastMode", false]), | |
| 117 | }); | |
| 118 | ||
| 119 | expect(state.modelOptionsForDispatch).toEqual( | |
| 120 | selections(["effort", "high"], ["fastMode", false]), | |
| 121 | ); | |
| 122 | }); | |
| 123 | ||
| 124 | it("drops selections for descriptors the model does not declare", () => { | |
| 125 | const state = getComposerProviderState({ | |
| 126 | provider: PROVIDER, | |
| 127 | model: MODEL, | |
| 128 | models: modelWith([booleanDescriptor("thinking")]), | |
| 129 | prompt: "", | |
| 130 | modelOptions: selections(["effort", "max"], ["thinking", false]), | |
| 131 | }); | |
| 132 | ||
| 133 | expect(state).toEqual({ | |
| 134 | provider: PROVIDER, | |
| 135 | promptEffort: null, | |
| 136 | modelOptionsForDispatch: selections(["thinking", false]), | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { | |
| 141 | const state = getComposerProviderState({ | |
| 142 | provider: PROVIDER, | |
| 143 | model: MODEL, | |
| 144 | models: modelWith([ | |
| 145 | selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), | |
| 146 | selectDescriptor("contextWindow", [ | |
| 147 | { id: "200k", label: "200k", isDefault: true }, | |
| 148 | { id: "1m", label: "1M" }, | |
| 149 | ]), | |
| 150 | selectDescriptor("agent", [ | |
| 151 | { id: "build", label: "Build", isDefault: true }, | |
| 152 | { id: "plan", label: "Plan" }, | |
| 153 | ]), | |
| 154 | ]), | |
| 155 | prompt: "", | |
| 156 | modelOptions: selections(["agent", "plan"]), | |
| 157 | }); | |
| 158 | ||
| 159 | expect(state.promptEffort).toBe("high"); | |
| 160 | expect(state.modelOptionsForDispatch).toEqual( | |
| 161 | selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]), | |
| 162 | ); | |
| 163 | }); | |
| 164 | ||
| 165 | it("returns undefined dispatch options when the model declares no descriptors", () => { | |
| 166 | const state = getComposerProviderState({ | |
| 167 | provider: PROVIDER, | |
| 168 | model: MODEL, | |
| 169 | models: modelWith([]), | |
| 170 | prompt: "", | |
| 171 | modelOptions: selections(["anything", "value"]), | |
| 172 | }); | |
| 173 | ||
| 174 | expect(state).toEqual({ | |
| 175 | provider: PROVIDER, | |
| 176 | promptEffort: null, | |
| 177 | modelOptionsForDispatch: undefined, | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => { | |
| 182 | const state = getComposerProviderState({ | |
| 183 | provider: PROVIDER, | |
| 184 | model: MODEL, | |
| 185 | models: modelWith([ | |
| 186 | selectDescriptor( | |
| 187 | "effort", | |
| 188 | [ | |
| 189 | { id: "medium", label: "Medium" }, | |
| 190 | { id: "high", label: "High", isDefault: true }, | |
| 191 | { id: "ultrathink", label: "Ultrathink" }, | |
| 192 | ], | |
| 193 | ["ultrathink"], | |
| 194 | ), | |
| 195 | ]), | |
| 196 | prompt: "Ultrathink:\nInvestigate this failure", | |
| 197 | modelOptions: selections(["effort", "medium"]), | |
| 198 | }); | |
| 199 | ||
| 200 | expect(state).toEqual({ | |
| 201 | provider: PROVIDER, | |
| 202 | promptEffort: "medium", | |
| 203 | modelOptionsForDispatch: selections(["effort", "medium"]), | |
| 204 | ...ULTRATHINK_FRAME_CLASSES, | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => { | |
| 209 | const state = getComposerProviderState({ | |
| 210 | provider: PROVIDER, | |
| 211 | model: MODEL, | |
| 212 | models: modelWith([ | |
| 213 | selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), | |
| 214 | ]), | |
| 215 | prompt: "Ultrathink:\nInvestigate this failure", | |
| 216 | modelOptions: undefined, | |
| 217 | }); | |
| 218 | ||
| 219 | expect(state).not.toHaveProperty("composerFrameClassName"); | |
| 220 | expect(state).not.toHaveProperty("composerSurfaceClassName"); | |
| 221 | expect(state).not.toHaveProperty("modelPickerIconClassName"); | |
| 222 | }); | |
| 223 | }); | |
| 224 | ||
| 225 | describe("provider traits render guards", () => { | |
| 226 | it("returns null when no thread target is provided", () => { | |
| 227 | const models = modelWith([ | |
| 228 | selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), | |
| 229 | ]); | |
| 230 | const args = { | |
| 231 | provider: PROVIDER, | |
| 232 | model: MODEL, | |
| 233 | models, | |
| 234 | modelOptions: undefined, | |
| 235 | prompt: "", | |
| 236 | onPromptChange: () => {}, | |
| 237 | }; | |
| 238 | ||
| 239 | expect(renderProviderTraitsPicker(args)).toBeNull(); | |
| 240 | expect(renderProviderTraitsMenuContent(args)).toBeNull(); | |
| 241 | }); | |
| 242 | }); |
apps/web/src/components/chat/composerProviderState.tsx created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { | |
| 2 | type ProviderKind, | |
| 3 | type ProviderOptionSelection, | |
| 4 | type ScopedThreadRef, | |
| 5 | type ServerProviderModel, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | buildProviderOptionSelectionsFromDescriptors, | |
| 9 | getProviderOptionCurrentValue, | |
| 10 | getProviderOptionDescriptors, | |
| 11 | isClaudeUltrathinkPrompt, | |
| 12 | } from "@t3tools/shared/model"; | |
| 13 | import type { ReactNode } from "react"; | |
| 14 | ||
| 15 | import type { DraftId } from "../../composerDraftStore"; | |
| 16 | import { getProviderModelCapabilities } from "../../providerModels"; | |
| 17 | import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | |
| 18 | ||
| 19 | export type ComposerProviderStateInput = { | |
| 20 | provider: ProviderKind; | |
| 21 | model: string; | |
| 22 | models: ReadonlyArray<ServerProviderModel>; | |
| 23 | prompt: string; | |
| 24 | modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined; | |
| 25 | }; | |
| 26 | ||
| 27 | export type ComposerProviderState = { | |
| 28 | provider: ProviderKind; | |
| 29 | promptEffort: string | null; | |
| 30 | modelOptionsForDispatch: ReadonlyArray<ProviderOptionSelection> | undefined; | |
| 31 | composerFrameClassName?: string; | |
| 32 | composerSurfaceClassName?: string; | |
| 33 | modelPickerIconClassName?: string; | |
| 34 | }; | |
| 35 | ||
| 36 | type TraitsRenderInput = { | |
| 37 | provider: ProviderKind; | |
| 38 | threadRef?: ScopedThreadRef; | |
| 39 | draftId?: DraftId; | |
| 40 | model: string; | |
| 41 | models: ReadonlyArray<ServerProviderModel>; | |
| 42 | modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined; | |
| 43 | prompt: string; | |
| 44 | onPromptChange: (prompt: string) => void; | |
| 45 | }; | |
| 46 | ||
| 47 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { | |
| 48 | const { provider, model, models, prompt, modelOptions } = input; | |
| 49 | const caps = getProviderModelCapabilities(models, model, provider); | |
| 50 | const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); | |
| 51 | const primarySelectDescriptor = descriptors.find( | |
| 52 | (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => | |
| 53 | descriptor.type === "select", | |
| 54 | ); | |
| 55 | const primaryValue = getProviderOptionCurrentValue(primarySelectDescriptor ?? null); | |
| 56 | const promptEffort = typeof primaryValue === "string" ? primaryValue : null; | |
| 57 | const ultrathinkActive = | |
| 58 | (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && | |
| 59 | isClaudeUltrathinkPrompt(prompt); | |
| 60 | ||
| 61 | return { | |
| 62 | provider, | |
| 63 | promptEffort, | |
| 64 | modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), | |
| 65 | ...(ultrathinkActive | |
| 66 | ? { | |
| 67 | composerFrameClassName: "ultrathink-frame", | |
| 68 | composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", | |
| 69 | modelPickerIconClassName: "ultrathink-chroma", | |
| 70 | } | |
| 71 | : {}), | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | function renderTraitsControl( | |
| 76 | Component: typeof TraitsMenuContent | typeof TraitsPicker, | |
| 77 | input: TraitsRenderInput, | |
| 78 | ): ReactNode { | |
| 79 | const { provider, threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = | |
| 80 | input; | |
| 81 | const hasTarget = threadRef !== undefined || draftId !== undefined; | |
| 82 | if ( | |
| 83 | !hasTarget || | |
| 84 | !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) | |
| 85 | ) { | |
| 86 | return null; | |
| 87 | } | |
| 88 | return ( | |
| 89 | <Component | |
| 90 | provider={provider} | |
| 91 | models={models} | |
| 92 | {...(threadRef ? { threadRef } : {})} | |
| 93 | {...(draftId ? { draftId } : {})} | |
| 94 | model={model} | |
| 95 | modelOptions={modelOptions} | |
| 96 | prompt={prompt} | |
| 97 | onPromptChange={onPromptChange} | |
| 98 | /> | |
| 99 | ); | |
| 100 | } | |
| 101 | ||
| 102 | export function renderProviderTraitsMenuContent(input: TraitsRenderInput): ReactNode { | |
| 103 | return renderTraitsControl(TraitsMenuContent, input); | |
| 104 | } | |
| 105 | ||
| 106 | export function renderProviderTraitsPicker(input: TraitsRenderInput): ReactNode { | |
| 107 | return renderTraitsControl(TraitsPicker, input); | |
| 108 | } |
apps/web/src/components/settings/SettingsPanels.tsx+20-8| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | import { useQueryClient } from "@tanstack/react-query"; |
| 12 | 12 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; |
| 13 | 13 | import { |
| 14 | PROVIDER_DISPLAY_NAMES, | |
| 15 | 14 | type DesktopUpdateChannel, |
| 16 | 15 | type ScopedThreadRef, |
| 17 | 16 | type ProviderKind, |
| ... | ... | @@ -20,8 +19,7 @@ import { |
| 20 | 19 | } from "@t3tools/contracts"; |
| 21 | 20 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 22 | 21 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 24 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 22 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 25 | 23 | import { Equal } from "effect"; |
| 26 | 24 | import { APP_VERSION } from "../../branding"; |
| 27 | 25 | import { |
| ... | ... | @@ -79,6 +77,7 @@ import { |
| 79 | 77 | useServerObservability, |
| 80 | 78 | useServerProviders, |
| 81 | 79 | } from "../../rpc/serverState"; |
| 80 | import { formatProviderKindLabel } from "../../providerModels"; | |
| 82 | 81 | |
| 83 | 82 | const THEME_OPTIONS = [ |
| 84 | 83 | { |
| ... | ... | @@ -1172,7 +1171,9 @@ export function GeneralSettingsPanel() { |
| 1172 | 1171 | const customModelInput = customModelInputByProvider[providerCard.provider]; |
| 1173 | 1172 | const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; |
| 1174 | 1173 | const providerDisplayName = |
| 1175 | PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; | |
| 1174 | providerCard.liveProvider?.displayName?.trim() || | |
| 1175 | providerCard.title || | |
| 1176 | formatProviderKindLabel(providerCard.provider); | |
| 1176 | 1177 | |
| 1177 | 1178 | return ( |
| 1178 | 1179 | <div key={providerCard.provider} className="border-t border-border first:border-t-0"> |
| ... | ... | @@ -1471,11 +1472,22 @@ export function GeneralSettingsPanel() { |
| 1471 | 1472 | {providerCard.models.map((model) => { |
| 1472 | 1473 | const caps = model.capabilities; |
| 1473 | 1474 | const capLabels: string[] = []; |
| 1474 | if (caps?.supportsFastMode) capLabels.push("Fast mode"); | |
| 1475 | if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); | |
| 1475 | const descriptors = caps?.optionDescriptors ?? []; | |
| 1476 | if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { | |
| 1477 | capLabels.push("Fast mode"); | |
| 1478 | } | |
| 1479 | if (descriptors.some((descriptor) => descriptor.id === "thinking")) { | |
| 1480 | capLabels.push("Thinking"); | |
| 1481 | } | |
| 1476 | 1482 | if ( |
| 1477 | caps?.reasoningEffortLevels && | |
| 1478 | caps.reasoningEffortLevels.length > 0 | |
| 1483 | descriptors.some( | |
| 1484 | (descriptor) => | |
| 1485 | descriptor.type === "select" && | |
| 1486 | (descriptor.id === "reasoningEffort" || | |
| 1487 | descriptor.id === "effort" || | |
| 1488 | descriptor.id === "reasoning" || | |
| 1489 | descriptor.id === "variant"), | |
| 1490 | ) | |
| 1479 | 1491 | ) { |
| 1480 | 1492 | capLabels.push("Reasoning"); |
| 1481 | 1493 | } |
apps/web/src/composerDraftStore.test.ts+77-64| ... | ... | @@ -10,8 +10,38 @@ import { |
| 10 | 10 | ProjectId, |
| 11 | 11 | ThreadId, |
| 12 | 12 | type ModelSelection, |
| 13 | type ProviderModelOptions, | |
| 13 | type ProviderKind, | |
| 14 | type ProviderOptionSelection, | |
| 14 | 15 | } from "@t3tools/contracts"; |
| 16 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 17 | ||
| 18 | type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>; | |
| 19 | type ProviderOptionSelectionsByProvider = Partial<Record<ProviderKind, ProviderOptionSelectionBag>>; | |
| 20 | ||
| 21 | function toSelections( | |
| 22 | options: Record<string, string | boolean | undefined> | undefined, | |
| 23 | ): ReadonlyArray<ProviderOptionSelection> { | |
| 24 | const result: Array<ProviderOptionSelection> = []; | |
| 25 | if (!options) return result; | |
| 26 | for (const [id, value] of Object.entries(options)) { | |
| 27 | if (typeof value === "string" || typeof value === "boolean") { | |
| 28 | result.push({ id, value }); | |
| 29 | } | |
| 30 | } | |
| 31 | return result; | |
| 32 | } | |
| 33 | ||
| 34 | function selectionsByProvider( | |
| 35 | options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>, | |
| 36 | ): ProviderOptionSelectionsByProvider { | |
| 37 | const result: ProviderOptionSelectionsByProvider = {}; | |
| 38 | for (const [provider, bag] of Object.entries(options) as Array< | |
| 39 | [ProviderKind, Record<string, string | boolean | undefined>] | |
| 40 | >) { | |
| 41 | result[provider] = toSelections(bag); | |
| 42 | } | |
| 43 | return result; | |
| 44 | } | |
| 15 | 45 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 16 | 46 | |
| 17 | 47 | import { |
| ... | ... | @@ -93,17 +123,15 @@ function resetComposerDraftStore() { |
| 93 | 123 | function modelSelection( |
| 94 | 124 | provider: "codex" | "claudeAgent" | "cursor", |
| 95 | 125 | model: string, |
| 96 | options?: ModelSelection["options"], | |
| 126 | options?: Record<string, string | boolean | undefined>, | |
| 97 | 127 | ): ModelSelection { |
| 98 | return { | |
| 99 | provider, | |
| 100 | model, | |
| 101 | ...(options ? { options } : {}), | |
| 102 | } as ModelSelection; | |
| 128 | return createModelSelection(provider, model, toSelections(options)); | |
| 103 | 129 | } |
| 104 | 130 | |
| 105 | function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { | |
| 106 | return options; | |
| 131 | function providerModelOptions( | |
| 132 | options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>, | |
| 133 | ): ProviderOptionSelectionsByProvider { | |
| 134 | return selectionsByProvider(options); | |
| 107 | 135 | } |
| 108 | 136 | |
| 109 | 137 | const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| ... | ... | @@ -941,14 +969,9 @@ describe("composerDraftStore modelSelection", () => { |
| 941 | 969 | }), |
| 942 | 970 | ); |
| 943 | 971 | |
| 944 | store.setProviderModelOptions( | |
| 945 | threadRef, | |
| 946 | "claudeAgent", | |
| 947 | { | |
| 948 | thinking: false, | |
| 949 | }, | |
| 950 | { persistSticky: true }, | |
| 951 | ); | |
| 972 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { | |
| 973 | persistSticky: true, | |
| 974 | }); | |
| 952 | 975 | |
| 953 | 976 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 954 | 977 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| ... | ... | @@ -972,9 +995,7 @@ describe("composerDraftStore modelSelection", () => { |
| 972 | 995 | }), |
| 973 | 996 | ); |
| 974 | 997 | |
| 975 | store.setProviderModelOptions(threadRef, "claudeAgent", { | |
| 976 | thinking: true, | |
| 977 | }); | |
| 998 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: true })); | |
| 978 | 999 | |
| 979 | 1000 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 980 | 1001 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| ... | ... | @@ -989,10 +1010,11 @@ describe("composerDraftStore modelSelection", () => { |
| 989 | 1010 | |
| 990 | 1011 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); |
| 991 | 1012 | |
| 992 | store.setProviderModelOptions(threadRef, "codex", { | |
| 993 | reasoningEffort: "high", | |
| 994 | fastMode: false, | |
| 995 | }); | |
| 1013 | store.setProviderModelOptions( | |
| 1014 | threadRef, | |
| 1015 | "codex", | |
| 1016 | toSelections({ reasoningEffort: "high", fastMode: false }), | |
| 1017 | ); | |
| 996 | 1018 | |
| 997 | 1019 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( |
| 998 | 1020 | modelSelection("codex", "gpt-5.4", { |
| ... | ... | @@ -1014,11 +1036,11 @@ describe("composerDraftStore modelSelection", () => { |
| 1014 | 1036 | }), |
| 1015 | 1037 | ); |
| 1016 | 1038 | |
| 1017 | store.setProviderModelOptions(threadRef, "cursor", { | |
| 1018 | reasoning: "medium", | |
| 1019 | fastMode: false, | |
| 1020 | thinking: true, | |
| 1021 | }); | |
| 1039 | store.setProviderModelOptions( | |
| 1040 | threadRef, | |
| 1041 | "cursor", | |
| 1042 | toSelections({ reasoning: "medium", fastMode: false, thinking: true }), | |
| 1043 | ); | |
| 1022 | 1044 | |
| 1023 | 1045 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( |
| 1024 | 1046 | modelSelection("cursor", "claude-opus-4-6", { |
| ... | ... | @@ -1032,17 +1054,10 @@ describe("composerDraftStore modelSelection", () => { |
| 1032 | 1054 | it("preserves the selected Cursor model when only traits change", () => { |
| 1033 | 1055 | const store = useComposerDraftStore.getState(); |
| 1034 | 1056 | |
| 1035 | store.setProviderModelOptions( | |
| 1036 | threadRef, | |
| 1037 | "cursor", | |
| 1038 | { | |
| 1039 | reasoning: "high", | |
| 1040 | }, | |
| 1041 | { | |
| 1042 | model: "gpt-5.4", | |
| 1043 | persistSticky: true, | |
| 1044 | }, | |
| 1045 | ); | |
| 1057 | store.setProviderModelOptions(threadRef, "cursor", toSelections({ reasoning: "high" }), { | |
| 1058 | model: "gpt-5.4", | |
| 1059 | persistSticky: true, | |
| 1060 | }); | |
| 1046 | 1061 | |
| 1047 | 1062 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( |
| 1048 | 1063 | modelSelection("cursor", "gpt-5.4", { |
| ... | ... | @@ -1067,9 +1082,7 @@ describe("composerDraftStore modelSelection", () => { |
| 1067 | 1082 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 1068 | 1083 | ); |
| 1069 | 1084 | |
| 1070 | store.setProviderModelOptions(threadRef, "claudeAgent", { | |
| 1071 | thinking: false, | |
| 1072 | }); | |
| 1085 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false })); | |
| 1073 | 1086 | |
| 1074 | 1087 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 1075 | 1088 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| ... | ... | @@ -1097,8 +1110,13 @@ describe("composerDraftStore modelSelection", () => { |
| 1097 | 1110 | store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); |
| 1098 | 1111 | |
| 1099 | 1112 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1100 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); | |
| 1101 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); | |
| 1113 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual( | |
| 1114 | createModelSelection("codex", "gpt-5.4", toSelections({ reasoningEffort: "xhigh" })).options, | |
| 1115 | ); | |
| 1116 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( | |
| 1117 | createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) | |
| 1118 | .options, | |
| 1119 | ); | |
| 1102 | 1120 | }); |
| 1103 | 1121 | |
| 1104 | 1122 | it("preserves other provider options when switching the active model selection", () => { |
| ... | ... | @@ -1118,7 +1136,9 @@ describe("composerDraftStore modelSelection", () => { |
| 1118 | 1136 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( |
| 1119 | 1137 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 1120 | 1138 | ); |
| 1121 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); | |
| 1139 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual( | |
| 1140 | createModelSelection("codex", "gpt-5.4", toSelections({ fastMode: true })).options, | |
| 1141 | ); | |
| 1122 | 1142 | expect(draft?.activeProvider).toBe("claudeAgent"); |
| 1123 | 1143 | }); |
| 1124 | 1144 | |
| ... | ... | @@ -1127,14 +1147,9 @@ describe("composerDraftStore modelSelection", () => { |
| 1127 | 1147 | |
| 1128 | 1148 | store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); |
| 1129 | 1149 | |
| 1130 | store.setProviderModelOptions( | |
| 1131 | threadRef, | |
| 1132 | "codex", | |
| 1133 | { | |
| 1134 | fastMode: true, | |
| 1135 | }, | |
| 1136 | { persistSticky: true }, | |
| 1137 | ); | |
| 1150 | store.setProviderModelOptions(threadRef, "codex", toSelections({ fastMode: true }), { | |
| 1151 | persistSticky: true, | |
| 1152 | }); | |
| 1138 | 1153 | |
| 1139 | 1154 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( |
| 1140 | 1155 | modelSelection("codex", "gpt-5.4", { |
| ... | ... | @@ -1154,14 +1169,9 @@ describe("composerDraftStore modelSelection", () => { |
| 1154 | 1169 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 1155 | 1170 | ); |
| 1156 | 1171 | |
| 1157 | store.setProviderModelOptions( | |
| 1158 | threadRef, | |
| 1159 | "claudeAgent", | |
| 1160 | { | |
| 1161 | thinking: false, | |
| 1162 | }, | |
| 1163 | { persistSticky: false }, | |
| 1164 | ); | |
| 1172 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }), { | |
| 1173 | persistSticky: false, | |
| 1174 | }); | |
| 1165 | 1175 | |
| 1166 | 1176 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( |
| 1167 | 1177 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| ... | ... | @@ -1279,12 +1289,15 @@ describe("composerDraftStore provider-scoped option updates", () => { |
| 1279 | 1289 | reasoningEffort: "medium", |
| 1280 | 1290 | }), |
| 1281 | 1291 | ); |
| 1282 | store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); | |
| 1292 | store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" })); | |
| 1283 | 1293 | const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); |
| 1284 | 1294 | expect(draft?.modelSelectionByProvider.codex).toEqual( |
| 1285 | 1295 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), |
| 1286 | 1296 | ); |
| 1287 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); | |
| 1297 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( | |
| 1298 | createModelSelection("claudeAgent", "claude-opus-4-6", toSelections({ effort: "max" })) | |
| 1299 | .options, | |
| 1300 | ); | |
| 1288 | 1301 | expect(draft?.activeProvider).toBe("codex"); |
| 1289 | 1302 | }); |
| 1290 | 1303 | }); |
apps/web/src/composerDraftStore.ts+181-218| ... | ... | @@ -1,16 +1,11 @@ |
| 1 | 1 | import { |
| 2 | CURSOR_REASONING_OPTIONS, | |
| 3 | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | type CursorModelOptions, | |
| 5 | type CursorReasoningOption, | |
| 6 | ClaudeAgentEffort, | |
| 7 | CodexReasoningEffort, | |
| 8 | 3 | type EnvironmentId, |
| 9 | 4 | ModelSelection, |
| 10 | 5 | ProjectId, |
| 11 | 6 | ProviderInteractionMode, |
| 12 | 7 | ProviderKind, |
| 13 | ProviderModelOptions, | |
| 8 | ProviderOptionSelection, | |
| 14 | 9 | RuntimeMode, |
| 15 | 10 | type ServerProvider, |
| 16 | 11 | type ScopedProjectRef, |
| ... | ... | @@ -46,7 +41,7 @@ import { getDefaultServerModel } from "./providerModels"; |
| 46 | 41 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 47 | 42 | |
| 48 | 43 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 49 | const COMPOSER_DRAFT_STORAGE_VERSION = 5; | |
| 44 | const COMPOSER_DRAFT_STORAGE_VERSION = 6; | |
| 50 | 45 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); |
| 51 | 46 | const isRuntimeMode = Schema.is(RuntimeMode); |
| 52 | 47 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; |
| ... | ... | @@ -106,23 +101,30 @@ const PersistedComposerThreadDraftState = Schema.Struct({ |
| 106 | 101 | }); |
| 107 | 102 | type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; |
| 108 | 103 | |
| 109 | const LegacyCodexFields = Schema.Struct({ | |
| 110 | effort: Schema.optionalKey(CodexReasoningEffort), | |
| 111 | codexFastMode: Schema.optionalKey(Schema.Boolean), | |
| 112 | serviceTier: Schema.optionalKey(Schema.String), | |
| 113 | }); | |
| 114 | type LegacyCodexFields = typeof LegacyCodexFields.Type; | |
| 104 | /** | |
| 105 | * Per-provider record of generic option selections. Used as a transient | |
| 106 | * representation when migrating legacy v2 storage payloads and when | |
| 107 | * deriving per-provider option bundles for downstream consumers. | |
| 108 | */ | |
| 109 | type ProviderOptionSelectionsByProvider = Partial< | |
| 110 | Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>> | |
| 111 | >; | |
| 115 | 112 | |
| 116 | const LegacyThreadModelFields = Schema.Struct({ | |
| 117 | provider: Schema.optionalKey(ProviderKind), | |
| 118 | model: Schema.optionalKey(Schema.String), | |
| 119 | modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), | |
| 120 | }); | |
| 121 | type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; | |
| 113 | type LegacyCodexFields = { | |
| 114 | effort?: unknown; | |
| 115 | codexFastMode?: unknown; | |
| 116 | serviceTier?: unknown; | |
| 117 | }; | |
| 118 | ||
| 119 | type LegacyThreadModelFields = { | |
| 120 | provider?: unknown; | |
| 121 | model?: unknown; | |
| 122 | modelOptions?: unknown; | |
| 123 | }; | |
| 122 | 124 | |
| 123 | 125 | type LegacyV2ThreadDraftFields = { |
| 124 | 126 | modelSelection?: ModelSelection | null; |
| 125 | modelOptions?: ProviderModelOptions | null; | |
| 127 | modelOptions?: unknown; | |
| 126 | 128 | }; |
| 127 | 129 | |
| 128 | 130 | type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & |
| ... | ... | @@ -130,16 +132,15 @@ type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState |
| 130 | 132 | LegacyThreadModelFields & |
| 131 | 133 | LegacyV2ThreadDraftFields; |
| 132 | 134 | |
| 133 | const LegacyStickyModelFields = Schema.Struct({ | |
| 134 | stickyProvider: Schema.optionalKey(ProviderKind), | |
| 135 | stickyModel: Schema.optionalKey(Schema.String), | |
| 136 | stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), | |
| 137 | }); | |
| 138 | type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; | |
| 135 | type LegacyStickyModelFields = { | |
| 136 | stickyProvider?: unknown; | |
| 137 | stickyModel?: unknown; | |
| 138 | stickyModelOptions?: unknown; | |
| 139 | }; | |
| 139 | 140 | |
| 140 | 141 | type LegacyV2StoreFields = { |
| 141 | 142 | stickyModelSelection?: ModelSelection | null; |
| 142 | stickyModelOptions?: ProviderModelOptions | null; | |
| 143 | stickyModelOptions?: unknown; | |
| 143 | 144 | projectDraftThreadIdByProjectId?: Record<string, string> | null; |
| 144 | 145 | draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null; |
| 145 | 146 | draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null; |
| ... | ... | @@ -334,15 +335,19 @@ interface ComposerDraftStoreState { |
| 334 | 335 | threadRef: ComposerThreadTarget, |
| 335 | 336 | modelSelection: ModelSelection | null | undefined, |
| 336 | 337 | ) => void; |
| 338 | /** Replace the model options for one or more providers in the draft. */ | |
| 337 | 339 | setModelOptions: ( |
| 338 | 340 | threadRef: ComposerThreadTarget, |
| 339 | modelOptions: ProviderModelOptions | null | undefined, | |
| 341 | modelOptions: | |
| 342 | | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> | |
| 343 | | null | |
| 344 | | undefined, | |
| 340 | 345 | ) => void; |
| 341 | 346 | applyStickyState: (threadRef: ComposerThreadTarget) => void; |
| 342 | 347 | setProviderModelOptions: ( |
| 343 | 348 | threadRef: ComposerThreadTarget, |
| 344 | 349 | provider: ProviderKind, |
| 345 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, | |
| 350 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 346 | 351 | options?: { |
| 347 | 352 | model?: string | null | undefined; |
| 348 | 353 | persistSticky?: boolean; |
| ... | ... | @@ -379,7 +384,7 @@ interface ComposerDraftStoreState { |
| 379 | 384 | |
| 380 | 385 | export interface EffectiveComposerModelState { |
| 381 | 386 | selectedModel: string; |
| 382 | modelOptions: ProviderModelOptions | null; | |
| 387 | modelOptions: ProviderOptionSelectionsByProvider | null; | |
| 383 | 388 | } |
| 384 | 389 | |
| 385 | 390 | interface ComposerDraftModelState { |
| ... | ... | @@ -387,29 +392,48 @@ interface ComposerDraftModelState { |
| 387 | 392 | modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; |
| 388 | 393 | } |
| 389 | 394 | |
| 390 | function providerModelOptionsFromSelection( | |
| 395 | function providerSelectionsFromModelSelection( | |
| 391 | 396 | modelSelection: ModelSelection | null | undefined, |
| 392 | ): ProviderModelOptions | null { | |
| 393 | if (!modelSelection?.options) { | |
| 397 | ): ProviderOptionSelectionsByProvider | null { | |
| 398 | if (!modelSelection) { | |
| 394 | 399 | return null; |
| 395 | 400 | } |
| 396 | ||
| 397 | return { | |
| 398 | [modelSelection.provider]: modelSelection.options, | |
| 399 | }; | |
| 401 | const options = modelSelection.options; | |
| 402 | if (!options || options.length === 0) { | |
| 403 | return null; | |
| 404 | } | |
| 405 | return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider; | |
| 400 | 406 | } |
| 401 | 407 | |
| 402 | 408 | function modelSelectionByProviderToOptions( |
| 403 | 409 | map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined, |
| 404 | ): ProviderModelOptions | null { | |
| 410 | ): ProviderOptionSelectionsByProvider | null { | |
| 405 | 411 | if (!map) return null; |
| 406 | const result: Record<string, unknown> = {}; | |
| 412 | const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; | |
| 407 | 413 | for (const [provider, selection] of Object.entries(map)) { |
| 408 | if (selection?.options) { | |
| 409 | result[provider] = selection.options; | |
| 414 | if (selection?.options && selection.options.length > 0) { | |
| 415 | result[provider as ProviderKind] = selection.options; | |
| 410 | 416 | } |
| 411 | 417 | } |
| 412 | return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null; | |
| 418 | return Object.keys(result).length > 0 ? result : null; | |
| 419 | } | |
| 420 | ||
| 421 | function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelection> { | |
| 422 | return { | |
| 423 | ...selection, | |
| 424 | ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}), | |
| 425 | } as DeepMutable<ModelSelection>; | |
| 426 | } | |
| 427 | ||
| 428 | function cloneModelSelectionByProvider( | |
| 429 | selections: Partial<Record<ProviderKind, ModelSelection>>, | |
| 430 | ): DeepMutable<Partial<Record<ProviderKind, ModelSelection>>> { | |
| 431 | return Object.fromEntries( | |
| 432 | Object.entries(selections).map(([provider, selection]) => [ | |
| 433 | provider, | |
| 434 | selection ? cloneModelSelection(selection) : selection, | |
| 435 | ]), | |
| 436 | ) as DeepMutable<Partial<Record<ProviderKind, ModelSelection>>>; | |
| 413 | 437 | } |
| 414 | 438 | |
| 415 | 439 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ |
| ... | ... | @@ -536,149 +560,92 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { |
| 536 | 560 | : null; |
| 537 | 561 | } |
| 538 | 562 | |
| 563 | /** | |
| 564 | * Coerce an unknown value into a `ReadonlyArray<ProviderOptionSelection>`. | |
| 565 | * Accepts either: | |
| 566 | * - the v3 representation: an array of `{ id, value }` entries | |
| 567 | * - the legacy v2 representation: a record of `{ id: string | boolean }` | |
| 568 | * | |
| 569 | * Validation is intentionally permissive: descriptors are the source of truth | |
| 570 | * for which option ids are meaningful for a given provider/model. Anything | |
| 571 | * outside the descriptor list is harmless trailing data and will simply be | |
| 572 | * ignored downstream. | |
| 573 | */ | |
| 574 | function coerceProviderOptionSelections( | |
| 575 | value: unknown, | |
| 576 | ): ReadonlyArray<ProviderOptionSelection> | undefined { | |
| 577 | if (Array.isArray(value)) { | |
| 578 | const out: ProviderOptionSelection[] = []; | |
| 579 | for (const entry of value) { | |
| 580 | if (!entry || typeof entry !== "object") continue; | |
| 581 | const record = entry as Record<string, unknown>; | |
| 582 | const id = record.id; | |
| 583 | const optionValue = record.value; | |
| 584 | if (typeof id !== "string" || id.length === 0) continue; | |
| 585 | if (typeof optionValue === "string" || typeof optionValue === "boolean") { | |
| 586 | out.push({ id, value: optionValue }); | |
| 587 | } | |
| 588 | } | |
| 589 | return out.length > 0 ? out : undefined; | |
| 590 | } | |
| 591 | if (value && typeof value === "object") { | |
| 592 | const record = value as Record<string, unknown>; | |
| 593 | const out: ProviderOptionSelection[] = []; | |
| 594 | for (const [id, raw] of Object.entries(record)) { | |
| 595 | if (typeof raw === "string" || typeof raw === "boolean") { | |
| 596 | out.push({ id, value: raw }); | |
| 597 | } | |
| 598 | } | |
| 599 | return out.length > 0 ? out : undefined; | |
| 600 | } | |
| 601 | return undefined; | |
| 602 | } | |
| 603 | ||
| 604 | /** | |
| 605 | * Normalize a per-provider options bag from either the v3 or legacy v2 shape. | |
| 606 | * | |
| 607 | * `provider` and `legacy` parameters are migration-only inputs used to | |
| 608 | * recover legacy codex fields (effort/codexFastMode/serviceTier) that lived | |
| 609 | * directly on the draft instead of inside `modelOptions.codex`. | |
| 610 | */ | |
| 539 | 611 | function normalizeProviderModelOptions( |
| 540 | 612 | value: unknown, |
| 541 | 613 | provider?: ProviderKind | null, |
| 542 | 614 | legacy?: LegacyCodexFields, |
| 543 | ): ProviderModelOptions | null { | |
| 615 | ): ProviderOptionSelectionsByProvider | null { | |
| 544 | 616 | const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; |
| 545 | const codexCandidate = | |
| 546 | candidate?.codex && typeof candidate.codex === "object" | |
| 547 | ? (candidate.codex as Record<string, unknown>) | |
| 548 | : null; | |
| 549 | const claudeCandidate = | |
| 550 | candidate?.claudeAgent && typeof candidate.claudeAgent === "object" | |
| 551 | ? (candidate.claudeAgent as Record<string, unknown>) | |
| 552 | : null; | |
| 553 | const cursorCandidate = | |
| 554 | candidate?.cursor && typeof candidate.cursor === "object" | |
| 555 | ? (candidate.cursor as Record<string, unknown>) | |
| 556 | : null; | |
| 557 | const openCodeCandidate = | |
| 558 | candidate?.opencode && typeof candidate.opencode === "object" | |
| 559 | ? (candidate.opencode as Record<string, unknown>) | |
| 560 | : null; | |
| 561 | ||
| 562 | const isCodexReasoningEffort = Schema.is(CodexReasoningEffort); | |
| 563 | const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort); | |
| 564 | ||
| 565 | const codexReasoningEffort = isCodexReasoningEffort(codexCandidate?.reasoningEffort) | |
| 566 | ? codexCandidate.reasoningEffort | |
| 567 | : provider === "codex" | |
| 568 | ? isCodexReasoningEffort(legacy?.effort) | |
| 569 | ? legacy.effort | |
| 570 | : undefined | |
| 571 | : undefined; | |
| 572 | const codexFastMode = | |
| 573 | codexCandidate?.fastMode === true | |
| 574 | ? true | |
| 575 | : codexCandidate?.fastMode === false | |
| 576 | ? false | |
| 577 | : (provider === "codex" && legacy?.codexFastMode === true) || | |
| 578 | (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") | |
| 579 | ? true | |
| 580 | : undefined; | |
| 581 | const codex = | |
| 582 | codexReasoningEffort !== undefined || codexFastMode !== undefined | |
| 583 | ? { | |
| 584 | ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), | |
| 585 | ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), | |
| 586 | } | |
| 587 | : undefined; | |
| 588 | ||
| 589 | const claudeThinking = | |
| 590 | claudeCandidate?.thinking === true | |
| 591 | ? true | |
| 592 | : claudeCandidate?.thinking === false | |
| 593 | ? false | |
| 594 | : undefined; | |
| 595 | const claudeEffort = isClaudeAgentEffort(claudeCandidate?.effort) | |
| 596 | ? claudeCandidate.effort | |
| 597 | : undefined; | |
| 598 | const claudeFastMode = | |
| 599 | claudeCandidate?.fastMode === true | |
| 600 | ? true | |
| 601 | : claudeCandidate?.fastMode === false | |
| 602 | ? false | |
| 603 | : undefined; | |
| 604 | const claudeContextWindow = | |
| 605 | typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0 | |
| 606 | ? claudeCandidate.contextWindow | |
| 607 | : undefined; | |
| 608 | const claude = | |
| 609 | claudeThinking !== undefined || | |
| 610 | claudeEffort !== undefined || | |
| 611 | claudeFastMode !== undefined || | |
| 612 | claudeContextWindow !== undefined | |
| 613 | ? { | |
| 614 | ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), | |
| 615 | ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), | |
| 616 | ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), | |
| 617 | ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), | |
| 618 | } | |
| 619 | : undefined; | |
| 620 | ||
| 621 | const cursorReasoningRaw = cursorCandidate?.reasoning; | |
| 622 | const cursorReasoning: CursorReasoningOption | undefined = | |
| 623 | typeof cursorReasoningRaw === "string" && | |
| 624 | (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) | |
| 625 | ? (cursorReasoningRaw as CursorReasoningOption) | |
| 626 | : undefined; | |
| 627 | const cursorFastMode = | |
| 628 | cursorCandidate?.fastMode === true | |
| 629 | ? true | |
| 630 | : cursorCandidate?.fastMode === false | |
| 631 | ? false | |
| 632 | : undefined; | |
| 633 | const cursorThinking = | |
| 634 | cursorCandidate?.thinking === true | |
| 635 | ? true | |
| 636 | : cursorCandidate?.thinking === false | |
| 637 | ? false | |
| 638 | : undefined; | |
| 639 | const cursorContextWindow = | |
| 640 | typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 | |
| 641 | ? cursorCandidate.contextWindow | |
| 642 | : undefined; | |
| 643 | ||
| 644 | const cursor: CursorModelOptions | undefined = | |
| 645 | cursorCandidate !== null | |
| 646 | ? (() => { | |
| 647 | const nextCursor = { | |
| 648 | ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), | |
| 649 | ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), | |
| 650 | ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), | |
| 651 | ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), | |
| 652 | } satisfies CursorModelOptions; | |
| 653 | return Object.keys(nextCursor).length > 0 ? nextCursor : undefined; | |
| 654 | })() | |
| 655 | : undefined; | |
| 656 | ||
| 657 | const openCodeVariant = | |
| 658 | typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 | |
| 659 | ? openCodeCandidate.variant | |
| 660 | : undefined; | |
| 661 | const openCodeAgent = | |
| 662 | typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 | |
| 663 | ? openCodeCandidate.agent | |
| 664 | : undefined; | |
| 665 | const opencode = | |
| 666 | openCodeVariant !== undefined || openCodeAgent !== undefined | |
| 667 | ? { | |
| 668 | ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), | |
| 669 | ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), | |
| 670 | } | |
| 671 | : undefined; | |
| 617 | const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; | |
| 618 | for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { | |
| 619 | const selections = coerceProviderOptionSelections(candidate?.[providerKey]); | |
| 620 | if (selections) { | |
| 621 | result[providerKey] = selections; | |
| 622 | } | |
| 623 | } | |
| 672 | 624 | |
| 673 | if (!codex && !claude && cursor === undefined && !opencode) { | |
| 674 | return null; | |
| 625 | // Recover legacy codex fields that lived outside modelOptions. | |
| 626 | if (provider === "codex" && legacy) { | |
| 627 | const codexExtras: ProviderOptionSelection[] = []; | |
| 628 | if (typeof legacy.effort === "string" && legacy.effort.length > 0) { | |
| 629 | codexExtras.push({ id: "reasoningEffort", value: legacy.effort }); | |
| 630 | } | |
| 631 | const fastMode = | |
| 632 | legacy.codexFastMode === true || | |
| 633 | (typeof legacy.serviceTier === "string" && legacy.serviceTier === "fast"); | |
| 634 | if (fastMode) { | |
| 635 | codexExtras.push({ id: "fastMode", value: true }); | |
| 636 | } | |
| 637 | if (codexExtras.length > 0) { | |
| 638 | const existing = result.codex ?? []; | |
| 639 | const existingIds = new Set(existing.map((entry) => entry.id)); | |
| 640 | const merged = [...existing]; | |
| 641 | for (const extra of codexExtras) { | |
| 642 | if (!existingIds.has(extra.id)) merged.push(extra); | |
| 643 | } | |
| 644 | result.codex = merged; | |
| 645 | } | |
| 675 | 646 | } |
| 676 | return { | |
| 677 | ...(codex ? { codex } : {}), | |
| 678 | ...(claude ? { claudeAgent: claude } : {}), | |
| 679 | ...(cursor !== undefined ? { cursor } : {}), | |
| 680 | ...(opencode ? { opencode } : {}), | |
| 681 | }; | |
| 647 | ||
| 648 | return Object.keys(result).length > 0 ? result : null; | |
| 682 | 649 | } |
| 683 | 650 | |
| 684 | 651 | function normalizeModelSelection( |
| ... | ... | @@ -703,6 +670,10 @@ function normalizeModelSelection( |
| 703 | 670 | if (!model) { |
| 704 | 671 | return null; |
| 705 | 672 | } |
| 673 | if (Array.isArray(candidate?.options)) { | |
| 674 | const selections = coerceProviderOptionSelections(candidate.options); | |
| 675 | return createModelSelection(provider, model, selections); | |
| 676 | } | |
| 706 | 677 | const modelOptions = normalizeProviderModelOptions( |
| 707 | 678 | candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, |
| 708 | 679 | provider, |
| ... | ... | @@ -716,7 +687,7 @@ function normalizeModelSelection( |
| 716 | 687 | |
| 717 | 688 | function legacySyncModelSelectionOptions( |
| 718 | 689 | modelSelection: ModelSelection | null, |
| 719 | modelOptions: ProviderModelOptions | null | undefined, | |
| 690 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, | |
| 720 | 691 | ): ModelSelection | null { |
| 721 | 692 | if (modelSelection === null) { |
| 722 | 693 | return null; |
| ... | ... | @@ -727,9 +698,9 @@ function legacySyncModelSelectionOptions( |
| 727 | 698 | |
| 728 | 699 | function legacyMergeModelSelectionIntoProviderModelOptions( |
| 729 | 700 | modelSelection: ModelSelection | null, |
| 730 | currentModelOptions: ProviderModelOptions | null | undefined, | |
| 731 | ): ProviderModelOptions | null { | |
| 732 | if (modelSelection?.options === undefined) { | |
| 701 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, | |
| 702 | ): ProviderOptionSelectionsByProvider | null { | |
| 703 | if (!modelSelection?.options || modelSelection.options.length === 0) { | |
| 733 | 704 | return normalizeProviderModelOptions(currentModelOptions); |
| 734 | 705 | } |
| 735 | 706 | return legacyReplaceProviderModelOptions( |
| ... | ... | @@ -740,35 +711,30 @@ function legacyMergeModelSelectionIntoProviderModelOptions( |
| 740 | 711 | } |
| 741 | 712 | |
| 742 | 713 | function legacyReplaceProviderModelOptions( |
| 743 | currentModelOptions: ProviderModelOptions | null | undefined, | |
| 714 | currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, | |
| 744 | 715 | provider: ProviderKind, |
| 745 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, | |
| 746 | ): ProviderModelOptions | null { | |
| 716 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 717 | ): ProviderOptionSelectionsByProvider | null { | |
| 747 | 718 | const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = |
| 748 | 719 | currentModelOptions ?? {}; |
| 749 | const normalizedNextProviderOptions = normalizeProviderModelOptions( | |
| 750 | { [provider]: nextProviderOptions }, | |
| 751 | provider, | |
| 752 | ); | |
| 753 | ||
| 754 | return normalizeProviderModelOptions({ | |
| 755 | ...otherProviderModelOptions, | |
| 756 | ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}), | |
| 757 | }); | |
| 720 | const merged: ProviderOptionSelectionsByProvider = { ...otherProviderModelOptions }; | |
| 721 | if (nextProviderOptions && nextProviderOptions.length > 0) { | |
| 722 | merged[provider] = nextProviderOptions; | |
| 723 | } | |
| 724 | return Object.keys(merged).length > 0 ? merged : null; | |
| 758 | 725 | } |
| 759 | 726 | |
| 760 | 727 | // ── New helpers for the consolidated representation ──────────────────── |
| 761 | 728 | |
| 762 | 729 | function legacyToModelSelectionByProvider( |
| 763 | 730 | modelSelection: ModelSelection | null, |
| 764 | modelOptions: ProviderModelOptions | null | undefined, | |
| 731 | modelOptions: ProviderOptionSelectionsByProvider | null | undefined, | |
| 765 | 732 | ): Partial<Record<ProviderKind, ModelSelection>> { |
| 766 | 733 | const result: Partial<Record<ProviderKind, ModelSelection>> = {}; |
| 767 | // Add entries from the options bag (for non-active providers) | |
| 768 | 734 | if (modelOptions) { |
| 769 | 735 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { |
| 770 | 736 | const options = modelOptions[provider]; |
| 771 | if (options && Object.keys(options).length > 0) { | |
| 737 | if (options && options.length > 0) { | |
| 772 | 738 | result[provider] = createModelSelection( |
| 773 | 739 | provider, |
| 774 | 740 | modelSelection?.provider === provider |
| ... | ... | @@ -779,7 +745,6 @@ function legacyToModelSelectionByProvider( |
| 779 | 745 | } |
| 780 | 746 | } |
| 781 | 747 | } |
| 782 | // Add/overwrite the active selection (it's authoritative for its provider) | |
| 783 | 748 | if (modelSelection) { |
| 784 | 749 | result[modelSelection.provider] = modelSelection; |
| 785 | 750 | } |
| ... | ... | @@ -813,8 +778,8 @@ export function deriveEffectiveComposerModelState(input: { |
| 813 | 778 | : baseModel; |
| 814 | 779 | const modelOptions = |
| 815 | 780 | modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? |
| 816 | providerModelOptionsFromSelection(input.threadModelSelection) ?? | |
| 817 | providerModelOptionsFromSelection(input.projectModelSelection) ?? | |
| 781 | providerSelectionsFromModelSelection(input.threadModelSelection) ?? | |
| 782 | providerSelectionsFromModelSelection(input.projectModelSelection) ?? | |
| 818 | 783 | null; |
| 819 | 784 | |
| 820 | 785 | return { |
| ... | ... | @@ -1425,7 +1390,7 @@ function normalizePersistedDraftsByThreadId( |
| 1425 | 1390 | { |
| 1426 | 1391 | provider: legacyDraftCandidate.provider, |
| 1427 | 1392 | model: legacyDraftCandidate.model, |
| 1428 | modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, | |
| 1393 | modelOptions: normalizedModelOptions ?? (legacyDraftCandidate.modelOptions as unknown), | |
| 1429 | 1394 | legacyCodex: legacyDraftCandidate, |
| 1430 | 1395 | }, |
| 1431 | 1396 | ); |
| ... | ... | @@ -1472,7 +1437,12 @@ function normalizePersistedDraftsByThreadId( |
| 1472 | 1437 | prompt, |
| 1473 | 1438 | attachments, |
| 1474 | 1439 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 1475 | ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), | |
| 1440 | ...(hasModelData | |
| 1441 | ? { | |
| 1442 | modelSelectionByProvider: cloneModelSelectionByProvider(modelSelectionByProvider), | |
| 1443 | activeProvider, | |
| 1444 | } | |
| 1445 | : {}), | |
| 1476 | 1446 | ...(runtimeMode ? { runtimeMode } : {}), |
| 1477 | 1447 | ...(interactionMode ? { interactionMode } : {}), |
| 1478 | 1448 | }; |
| ... | ... | @@ -1573,7 +1543,7 @@ function partializeComposerDraftStoreState( |
| 1573 | 1543 | : {}), |
| 1574 | 1544 | ...(hasModelData |
| 1575 | 1545 | ? { |
| 1576 | modelSelectionByProvider: draft.modelSelectionByProvider, | |
| 1546 | modelSelectionByProvider: cloneModelSelectionByProvider(draft.modelSelectionByProvider), | |
| 1577 | 1547 | activeProvider: draft.activeProvider, |
| 1578 | 1548 | } |
| 1579 | 1549 | : {}), |
| ... | ... | @@ -2304,27 +2274,24 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2304 | 2274 | if (threadKey.length === 0) { |
| 2305 | 2275 | return; |
| 2306 | 2276 | } |
| 2307 | const normalizedOpts = normalizeProviderModelOptions(modelOptions); | |
| 2308 | 2277 | set((state) => { |
| 2309 | 2278 | const existing = state.draftsByThreadKey[threadKey]; |
| 2310 | if (!existing && normalizedOpts === null) { | |
| 2279 | if (!existing && (!modelOptions || Object.keys(modelOptions).length === 0)) { | |
| 2311 | 2280 | return state; |
| 2312 | 2281 | } |
| 2313 | 2282 | const base = existing ?? createEmptyThreadDraft(); |
| 2314 | 2283 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2315 | 2284 | for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { |
| 2316 | // Only touch providers explicitly present in the input | |
| 2317 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; | |
| 2318 | const opts = normalizedOpts[provider]; | |
| 2285 | if (!modelOptions || !(provider in modelOptions)) continue; | |
| 2286 | const opts = modelOptions[provider]; | |
| 2319 | 2287 | const current = nextMap[provider]; |
| 2320 | if (opts) { | |
| 2288 | if (opts && opts.length > 0) { | |
| 2321 | 2289 | nextMap[provider] = createModelSelection( |
| 2322 | 2290 | provider, |
| 2323 | 2291 | current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], |
| 2324 | 2292 | opts, |
| 2325 | 2293 | ); |
| 2326 | 2294 | } else if (current?.options) { |
| 2327 | // Remove options but keep the selection | |
| 2328 | 2295 | const { options: _, ...rest } = current; |
| 2329 | 2296 | nextMap[provider] = rest as ModelSelection; |
| 2330 | 2297 | } |
| ... | ... | @@ -2357,12 +2324,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2357 | 2324 | const fallbackModel = |
| 2358 | 2325 | normalizeModelSlug(options?.model, normalizedProvider) ?? |
| 2359 | 2326 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider]; |
| 2360 | // Normalize just this provider's options | |
| 2361 | const normalizedOpts = normalizeProviderModelOptions( | |
| 2362 | { [normalizedProvider]: nextProviderOptions }, | |
| 2363 | normalizedProvider, | |
| 2364 | ); | |
| 2365 | const providerOpts = normalizedOpts?.[normalizedProvider]; | |
| 2327 | const providerOpts = | |
| 2328 | nextProviderOptions && nextProviderOptions.length > 0 ? nextProviderOptions : undefined; | |
| 2366 | 2329 | |
| 2367 | 2330 | set((state) => { |
| 2368 | 2331 | const existing = state.draftsByThreadKey[threadKey]; |
| ... | ... | @@ -2377,7 +2340,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2377 | 2340 | currentForProvider?.model ?? fallbackModel, |
| 2378 | 2341 | providerOpts, |
| 2379 | 2342 | ); |
| 2380 | } else if (currentForProvider?.options) { | |
| 2343 | } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) { | |
| 2381 | 2344 | const { options: _, ...rest } = currentForProvider; |
| 2382 | 2345 | nextMap[normalizedProvider] = rest as ModelSelection; |
| 2383 | 2346 | } |
| ... | ... | @@ -2397,7 +2360,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2397 | 2360 | stickyBase.model, |
| 2398 | 2361 | providerOpts, |
| 2399 | 2362 | ); |
| 2400 | } else if (stickyBase.options) { | |
| 2363 | } else if ((stickyBase.options?.length ?? 0) > 0) { | |
| 2401 | 2364 | const { options: _, ...rest } = stickyBase; |
| 2402 | 2365 | nextStickyMap[normalizedProvider] = rest as ModelSelection; |
| 2403 | 2366 | } |
apps/web/src/modelSelection.ts+2-45| ... | ... | @@ -9,7 +9,7 @@ import { |
| 9 | 9 | normalizeModelSlug, |
| 10 | 10 | resolveSelectableModel, |
| 11 | 11 | } from "@t3tools/shared/model"; |
| 12 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; | |
| 12 | import { getComposerProviderState } from "./components/chat/composerProviderState"; | |
| 13 | 13 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 14 | 14 | import { |
| 15 | 15 | getDefaultServerModel, |
| ... | ... | @@ -21,14 +21,6 @@ import { ModelEsque } from "./components/chat/providerIconUtils"; |
| 21 | 21 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 22 | 22 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 23 | 23 | |
| 24 | export type ProviderCustomModelConfig = { | |
| 25 | provider: ProviderKind; | |
| 26 | title: string; | |
| 27 | description: string; | |
| 28 | placeholder: string; | |
| 29 | example: string; | |
| 30 | }; | |
| 31 | ||
| 32 | 24 | export interface AppModelOption { |
| 33 | 25 | slug: string; |
| 34 | 26 | name: string; |
| ... | ... | @@ -37,39 +29,6 @@ export interface AppModelOption { |
| 37 | 29 | isCustom: boolean; |
| 38 | 30 | } |
| 39 | 31 | |
| 40 | const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = { | |
| 41 | codex: { | |
| 42 | provider: "codex", | |
| 43 | title: "Codex", | |
| 44 | description: "Save additional Codex model slugs for the picker and `/model` command.", | |
| 45 | placeholder: "your-codex-model-slug", | |
| 46 | example: "gpt-6.7-codex-ultra-preview", | |
| 47 | }, | |
| 48 | claudeAgent: { | |
| 49 | provider: "claudeAgent", | |
| 50 | title: "Claude", | |
| 51 | description: "Save additional Claude model slugs for the picker and `/model` command.", | |
| 52 | placeholder: "your-claude-model-slug", | |
| 53 | example: "claude-sonnet-5-0", | |
| 54 | }, | |
| 55 | cursor: { | |
| 56 | provider: "cursor", | |
| 57 | title: "Cursor", | |
| 58 | description: "Save additional Cursor model slugs for the picker and `/model` command.", | |
| 59 | placeholder: "your-cursor-model-slug", | |
| 60 | example: "claude-sonnet-4-6", | |
| 61 | }, | |
| 62 | opencode: { | |
| 63 | provider: "opencode", | |
| 64 | title: "OpenCode", | |
| 65 | description: "Save additional OpenCode model slugs in `provider/model` format.", | |
| 66 | placeholder: "openai/gpt-5", | |
| 67 | example: "anthropic/claude-sonnet-4-5-20250929", | |
| 68 | }, | |
| 69 | }; | |
| 70 | ||
| 71 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); | |
| 72 | ||
| 73 | 32 | export function normalizeCustomModelSlugs( |
| 74 | 33 | models: Iterable<string | null | undefined>, |
| 75 | 34 | builtInModelSlugs: ReadonlySet<string>, |
| ... | ... | @@ -222,9 +181,7 @@ export function resolveAppModelSelectionState( |
| 222 | 181 | model, |
| 223 | 182 | models: getProviderModels(providers, provider), |
| 224 | 183 | prompt: "", |
| 225 | modelOptions: { | |
| 226 | [provider]: provider === selection.provider ? selection.options : undefined, | |
| 227 | }, | |
| 184 | modelOptions: provider === selection.provider ? selection.options : undefined, | |
| 228 | 185 | }); |
| 229 | 186 | |
| 230 | 187 | return createModelSelection(provider, model, modelOptionsForDispatch); |
apps/web/src/providerModels.ts+27-41| ... | ... | @@ -1,25 +1,23 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | type CursorModelOptions, | |
| 4 | 3 | type ModelCapabilities, |
| 5 | 4 | type ProviderKind, |
| 6 | 5 | type ServerProvider, |
| 7 | 6 | type ServerProviderModel, |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | import { | |
| 10 | hasEffortLevel, | |
| 11 | normalizeModelSlug, | |
| 12 | resolveContextWindow, | |
| 13 | trimOrNull, | |
| 14 | } from "@t3tools/shared/model"; | |
| 8 | import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 9 | ||
| 10 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 11 | optionDescriptors: [], | |
| 12 | }); | |
| 15 | 13 | |
| 16 | const EMPTY_CAPABILITIES: ModelCapabilities = { | |
| 17 | reasoningEffortLevels: [], | |
| 18 | supportsFastMode: false, | |
| 19 | supportsThinkingToggle: false, | |
| 20 | contextWindowOptions: [], | |
| 21 | promptInjectedEffortLevels: [], | |
| 22 | }; | |
| 14 | export function formatProviderKindLabel(provider: ProviderKind): string { | |
| 15 | return provider | |
| 16 | .replace(/([a-z])([A-Z])/g, "$1 $2") | |
| 17 | .replace(/[_-]+/g, " ") | |
| 18 | .trim() | |
| 19 | .replace(/\b\w/g, (char) => char.toUpperCase()); | |
| 20 | } | |
| 23 | 21 | |
| 24 | 22 | export function getProviderModels( |
| 25 | 23 | providers: ReadonlyArray<ServerProvider>, |
| ... | ... | @@ -35,6 +33,21 @@ export function getProviderSnapshot( |
| 35 | 33 | return providers.find((candidate) => candidate.provider === provider); |
| 36 | 34 | } |
| 37 | 35 | |
| 36 | export function getProviderDisplayName( | |
| 37 | providers: ReadonlyArray<ServerProvider>, | |
| 38 | provider: ProviderKind, | |
| 39 | ): string { | |
| 40 | const snapshot = getProviderSnapshot(providers, provider); | |
| 41 | return snapshot?.displayName?.trim() || formatProviderKindLabel(provider); | |
| 42 | } | |
| 43 | ||
| 44 | export function getProviderInteractionModeToggle( | |
| 45 | providers: ReadonlyArray<ServerProvider>, | |
| 46 | provider: ProviderKind, | |
| 47 | ): boolean { | |
| 48 | return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; | |
| 49 | } | |
| 50 | ||
| 38 | 51 | export function isProviderEnabled( |
| 39 | 52 | providers: ReadonlyArray<ServerProvider>, |
| 40 | 53 | provider: ProviderKind, |
| ... | ... | @@ -76,30 +89,3 @@ export function getDefaultServerModel( |
| 76 | 89 | DEFAULT_MODEL_BY_PROVIDER[provider] |
| 77 | 90 | ); |
| 78 | 91 | } |
| 79 | ||
| 80 | export function normalizeCursorModelOptionsWithCapabilities( | |
| 81 | caps: ModelCapabilities, | |
| 82 | modelOptions: CursorModelOptions | null | undefined, | |
| 83 | ): CursorModelOptions | undefined { | |
| 84 | const reasoning = trimOrNull(modelOptions?.reasoning); | |
| 85 | const reasoningValue = | |
| 86 | reasoning && hasEffortLevel(caps, reasoning) | |
| 87 | ? (reasoning as CursorModelOptions["reasoning"]) | |
| 88 | : undefined; | |
| 89 | const fastMode = | |
| 90 | caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" | |
| 91 | ? modelOptions.fastMode | |
| 92 | : undefined; | |
| 93 | const thinking = | |
| 94 | caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" | |
| 95 | ? modelOptions.thinking | |
| 96 | : undefined; | |
| 97 | const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); | |
| 98 | const nextOptions: CursorModelOptions = { | |
| 99 | ...(reasoningValue ? { reasoning: reasoningValue } : {}), | |
| 100 | ...(fastMode !== undefined ? { fastMode } : {}), | |
| 101 | ...(thinking !== undefined ? { thinking } : {}), | |
| 102 | ...(contextWindow ? { contextWindow } : {}), | |
| 103 | }; | |
| 104 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 105 | } |
packages/contracts/src/model.ts+110-70| ... | ... | @@ -1,87 +1,127 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import { Effect, Schema, SchemaTransformation } from "effect"; | |
| 2 | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | import type { ProviderKind } from "./orchestration.ts"; |
| 4 | 4 | |
| 5 | export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | |
| 6 | export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); | |
| 7 | export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; | |
| 8 | export const CLAUDE_AGENT_EFFORT_OPTIONS = [ | |
| 9 | "low", | |
| 10 | "medium", | |
| 11 | "high", | |
| 12 | "xhigh", | |
| 13 | "max", | |
| 14 | "ultrathink", | |
| 15 | ] as const; | |
| 16 | export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); | |
| 17 | export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; | |
| 18 | export type ClaudeCodeEffort = ClaudeAgentEffort; | |
| 19 | export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; | |
| 20 | export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS); | |
| 21 | export type CursorReasoningOption = typeof CursorReasoningOption.Type; | |
| 22 | ||
| 23 | export type ProviderReasoningEffort = | |
| 24 | | CodexReasoningEffort | |
| 25 | | ClaudeAgentEffort | |
| 26 | | CursorReasoningOption; | |
| 27 | ||
| 28 | export const CodexModelOptions = Schema.Struct({ | |
| 29 | reasoningEffort: Schema.optional(CodexReasoningEffort), | |
| 30 | fastMode: Schema.optional(Schema.Boolean), | |
| 31 | }); | |
| 32 | export type CodexModelOptions = typeof CodexModelOptions.Type; | |
| 5 | export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); | |
| 6 | export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type; | |
| 33 | 7 | |
| 34 | export const ClaudeModelOptions = Schema.Struct({ | |
| 35 | thinking: Schema.optional(Schema.Boolean), | |
| 36 | effort: Schema.optional(ClaudeAgentEffort), | |
| 37 | fastMode: Schema.optional(Schema.Boolean), | |
| 38 | contextWindow: Schema.optional(Schema.String), | |
| 8 | export const ProviderOptionChoice = Schema.Struct({ | |
| 9 | id: TrimmedNonEmptyString, | |
| 10 | label: TrimmedNonEmptyString, | |
| 11 | description: Schema.optional(TrimmedNonEmptyString), | |
| 12 | isDefault: Schema.optional(Schema.Boolean), | |
| 39 | 13 | }); |
| 40 | export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; | |
| 14 | export type ProviderOptionChoice = typeof ProviderOptionChoice.Type; | |
| 41 | 15 | |
| 42 | export const CursorModelOptions = Schema.Struct({ | |
| 43 | reasoning: Schema.optional(CursorReasoningOption), | |
| 44 | fastMode: Schema.optional(Schema.Boolean), | |
| 45 | thinking: Schema.optional(Schema.Boolean), | |
| 46 | contextWindow: Schema.optional(Schema.String), | |
| 47 | }); | |
| 48 | export type CursorModelOptions = typeof CursorModelOptions.Type; | |
| 49 | export const OpenCodeModelOptions = Schema.Struct({ | |
| 50 | variant: Schema.optional(TrimmedNonEmptyString), | |
| 51 | agent: Schema.optional(TrimmedNonEmptyString), | |
| 52 | }); | |
| 53 | export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; | |
| 16 | const ProviderOptionDescriptorBase = { | |
| 17 | id: TrimmedNonEmptyString, | |
| 18 | label: TrimmedNonEmptyString, | |
| 19 | description: Schema.optional(TrimmedNonEmptyString), | |
| 20 | } as const; | |
| 54 | 21 | |
| 55 | export const ProviderModelOptions = Schema.Struct({ | |
| 56 | codex: Schema.optional(CodexModelOptions), | |
| 57 | claudeAgent: Schema.optional(ClaudeModelOptions), | |
| 58 | cursor: Schema.optional(CursorModelOptions), | |
| 59 | opencode: Schema.optional(OpenCodeModelOptions), | |
| 22 | export const SelectProviderOptionDescriptor = Schema.Struct({ | |
| 23 | ...ProviderOptionDescriptorBase, | |
| 24 | type: Schema.Literal("select"), | |
| 25 | options: Schema.Array(ProviderOptionChoice), | |
| 26 | currentValue: Schema.optional(TrimmedNonEmptyString), | |
| 27 | promptInjectedValues: Schema.optional(Schema.Array(TrimmedNonEmptyString)), | |
| 60 | 28 | }); |
| 61 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; | |
| 29 | export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type; | |
| 62 | 30 | |
| 63 | export const EffortOption = Schema.Struct({ | |
| 64 | value: TrimmedNonEmptyString, | |
| 65 | label: TrimmedNonEmptyString, | |
| 66 | isDefault: Schema.optional(Schema.Boolean), | |
| 31 | export const BooleanProviderOptionDescriptor = Schema.Struct({ | |
| 32 | ...ProviderOptionDescriptorBase, | |
| 33 | type: Schema.Literal("boolean"), | |
| 34 | currentValue: Schema.optional(Schema.Boolean), | |
| 67 | 35 | }); |
| 68 | export type EffortOption = typeof EffortOption.Type; | |
| 36 | export type BooleanProviderOptionDescriptor = typeof BooleanProviderOptionDescriptor.Type; | |
| 69 | 37 | |
| 70 | export const ContextWindowOption = Schema.Struct({ | |
| 71 | value: TrimmedNonEmptyString, | |
| 72 | label: TrimmedNonEmptyString, | |
| 73 | isDefault: Schema.optional(Schema.Boolean), | |
| 38 | export const ProviderOptionDescriptor = Schema.Union([ | |
| 39 | SelectProviderOptionDescriptor, | |
| 40 | BooleanProviderOptionDescriptor, | |
| 41 | ]); | |
| 42 | export type ProviderOptionDescriptor = typeof ProviderOptionDescriptor.Type; | |
| 43 | ||
| 44 | export const ProviderOptionSelectionValue = Schema.Union([TrimmedNonEmptyString, Schema.Boolean]); | |
| 45 | export type ProviderOptionSelectionValue = typeof ProviderOptionSelectionValue.Type; | |
| 46 | ||
| 47 | export const ProviderOptionSelection = Schema.Struct({ | |
| 48 | id: TrimmedNonEmptyString, | |
| 49 | value: ProviderOptionSelectionValue, | |
| 74 | 50 | }); |
| 75 | export type ContextWindowOption = typeof ContextWindowOption.Type; | |
| 51 | export type ProviderOptionSelection = typeof ProviderOptionSelection.Type; | |
| 52 | ||
| 53 | /** | |
| 54 | * Legacy on-disk shape for provider option selections, kept readable by the | |
| 55 | * decoder so we can tolerate stored data written before the v3 array shape. | |
| 56 | * | |
| 57 | * Persisted historically as `{ effort: "max", fastMode: true, ... }` inside | |
| 58 | * `modelSelection.options`. Migration 026 rewrites stored rows to the | |
| 59 | * canonical array shape, but we still see the legacy form in: | |
| 60 | * - `settings.json` files from older client builds, | |
| 61 | * - SQLite databases that have not yet run migration 026, | |
| 62 | * - any future regression that re-introduces the legacy shape. | |
| 63 | */ | |
| 64 | const LegacyProviderOptionSelectionsObject = Schema.Record(Schema.String, Schema.Unknown); | |
| 65 | ||
| 66 | const ProviderOptionSelectionsFromLegacyObject = LegacyProviderOptionSelectionsObject.pipe( | |
| 67 | Schema.decodeTo( | |
| 68 | Schema.Array(ProviderOptionSelection), | |
| 69 | SchemaTransformation.transformOrFail({ | |
| 70 | decode: (record) => Effect.succeed(coerceLegacyOptionsObjectToArray(record)), | |
| 71 | encode: (selections) => Effect.succeed(canonicalSelectionsToLegacyObject(selections)), | |
| 72 | }), | |
| 73 | ), | |
| 74 | ); | |
| 75 | ||
| 76 | /** | |
| 77 | * Schema for the `options` field of every `ModelSelection` variant. | |
| 78 | * | |
| 79 | * Accepts both: | |
| 80 | * - the canonical array shape `Array<{ id, value }>` (preferred), and | |
| 81 | * - the legacy object shape `Record<string, string | boolean | …>` from | |
| 82 | * pre-migration data. | |
| 83 | * | |
| 84 | * Always normalizes to the canonical array on decode and re-encodes as the | |
| 85 | * canonical array, so any legacy storage gets cleaned up the next time the | |
| 86 | * containing record is written back. | |
| 87 | */ | |
| 88 | export const ProviderOptionSelections = Schema.Union([ | |
| 89 | Schema.Array(ProviderOptionSelection), | |
| 90 | ProviderOptionSelectionsFromLegacyObject, | |
| 91 | ]); | |
| 92 | export type ProviderOptionSelections = typeof ProviderOptionSelections.Type; | |
| 93 | ||
| 94 | function coerceLegacyOptionsObjectToArray( | |
| 95 | record: Record<string, unknown>, | |
| 96 | ): ReadonlyArray<ProviderOptionSelection> { | |
| 97 | const entries: Array<ProviderOptionSelection> = []; | |
| 98 | for (const [rawKey, rawValue] of Object.entries(record)) { | |
| 99 | const id = typeof rawKey === "string" ? rawKey.trim() : ""; | |
| 100 | if (!id) continue; | |
| 101 | if (typeof rawValue === "string") { | |
| 102 | const trimmed = rawValue.trim(); | |
| 103 | if (trimmed) entries.push({ id, value: trimmed }); | |
| 104 | } else if (typeof rawValue === "boolean") { | |
| 105 | entries.push({ id, value: rawValue }); | |
| 106 | } | |
| 107 | // Drop anything else (numbers, null, nested objects/arrays) to match the | |
| 108 | // permissive normalization performed by migration 026. | |
| 109 | } | |
| 110 | return entries; | |
| 111 | } | |
| 112 | ||
| 113 | function canonicalSelectionsToLegacyObject( | |
| 114 | selections: ReadonlyArray<ProviderOptionSelection>, | |
| 115 | ): Record<string, string | boolean> { | |
| 116 | const out: Record<string, string | boolean> = {}; | |
| 117 | for (const { id, value } of selections) { | |
| 118 | out[id] = value; | |
| 119 | } | |
| 120 | return out; | |
| 121 | } | |
| 76 | 122 | |
| 77 | 123 | export const ModelCapabilities = Schema.Struct({ |
| 78 | reasoningEffortLevels: Schema.Array(EffortOption), | |
| 79 | supportsFastMode: Schema.Boolean, | |
| 80 | supportsThinkingToggle: Schema.Boolean, | |
| 81 | contextWindowOptions: Schema.Array(ContextWindowOption), | |
| 82 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), | |
| 83 | variantOptions: Schema.optional(Schema.Array(EffortOption)), | |
| 84 | agentOptions: Schema.optional(Schema.Array(EffortOption)), | |
| 124 | optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), | |
| 85 | 125 | }); |
| 86 | 126 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 87 | 127 |
packages/contracts/src/orchestration.test.ts+89-4| ... | ... | @@ -33,6 +33,13 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( |
| 33 | 33 | const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); |
| 34 | 34 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 35 | 35 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 36 | ||
| 37 | function getOptionValue( | |
| 38 | options: ReadonlyArray<{ id: string; value: unknown }> | undefined, | |
| 39 | id: string, | |
| 40 | ): unknown { | |
| 41 | return options?.find((option) => option.id === id)?.value; | |
| 42 | } | |
| 36 | 43 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); |
| 37 | 44 | const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); |
| 38 | 45 | const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); |
| ... | ... | @@ -364,19 +371,97 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => |
| 364 | 371 | modelSelection: { |
| 365 | 372 | provider: "codex", |
| 366 | 373 | model: "gpt-5.3-codex", |
| 374 | options: [ | |
| 375 | { id: "reasoningEffort", value: "high" }, | |
| 376 | { id: "fastMode", value: true }, | |
| 377 | ], | |
| 378 | }, | |
| 379 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 380 | }); | |
| 381 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); | |
| 382 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); | |
| 383 | assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); | |
| 384 | }), | |
| 385 | ); | |
| 386 | ||
| 387 | it.effect("normalizes legacy object-shaped modelSelection.options on decode", () => | |
| 388 | Effect.gen(function* () { | |
| 389 | const parsed = yield* decodeThreadCreatedPayload({ | |
| 390 | threadId: "thread-1", | |
| 391 | projectId: "project-1", | |
| 392 | title: "Legacy options thread", | |
| 393 | modelSelection: { | |
| 394 | provider: "claudeAgent", | |
| 395 | model: "claude-opus-4-6", | |
| 367 | 396 | options: { |
| 368 | reasoningEffort: "high", | |
| 397 | effort: "max", | |
| 369 | 398 | fastMode: true, |
| 399 | // Falsy/garbage entries are dropped, matching migration 026. | |
| 400 | emptyStr: " ", | |
| 401 | nullish: null, | |
| 402 | nested: { foo: 1 }, | |
| 370 | 403 | }, |
| 371 | 404 | }, |
| 405 | branch: null, | |
| 406 | worktreePath: null, | |
| 372 | 407 | createdAt: "2026-01-01T00:00:00.000Z", |
| 408 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 373 | 409 | }); |
| 374 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); | |
| 375 | assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); | |
| 376 | assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); | |
| 410 | ||
| 411 | assert.strictEqual(parsed.modelSelection.provider, "claudeAgent"); | |
| 412 | assert.deepStrictEqual(parsed.modelSelection.options, [ | |
| 413 | { id: "effort", value: "max" }, | |
| 414 | { id: "fastMode", value: true }, | |
| 415 | ]); | |
| 416 | }), | |
| 417 | ); | |
| 418 | ||
| 419 | it.effect("normalizes legacy object-shaped defaultModelSelection.options on decode", () => | |
| 420 | Effect.gen(function* () { | |
| 421 | const parsed = yield* decodeProjectCreatedPayload({ | |
| 422 | projectId: "project-1", | |
| 423 | title: "Legacy default project", | |
| 424 | workspaceRoot: "/tmp/legacy", | |
| 425 | defaultModelSelection: { | |
| 426 | provider: "codex", | |
| 427 | model: "gpt-5.4", | |
| 428 | options: { reasoningEffort: "low" }, | |
| 429 | }, | |
| 430 | scripts: [], | |
| 431 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 432 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 433 | }); | |
| 434 | ||
| 435 | assert.deepStrictEqual(parsed.defaultModelSelection?.options, [ | |
| 436 | { id: "reasoningEffort", value: "low" }, | |
| 437 | ]); | |
| 377 | 438 | }), |
| 378 | 439 | ); |
| 379 | 440 | |
| 441 | it.effect( | |
| 442 | "normalizes legacy object-shaped options on decode and re-encodes as canonical array", | |
| 443 | () => | |
| 444 | Effect.gen(function* () { | |
| 445 | const decoded = yield* decodeThreadCreatedPayload({ | |
| 446 | threadId: "thread-1", | |
| 447 | projectId: "project-1", | |
| 448 | title: "Round trip thread", | |
| 449 | modelSelection: { | |
| 450 | provider: "codex", | |
| 451 | model: "gpt-5.4", | |
| 452 | options: { fastMode: true }, | |
| 453 | }, | |
| 454 | branch: null, | |
| 455 | worktreePath: null, | |
| 456 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 457 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 458 | }); | |
| 459 | ||
| 460 | const encoded = yield* Schema.encodeEffect(ThreadCreatedPayload)(decoded); | |
| 461 | assert.deepStrictEqual(encoded.modelSelection.options, [{ id: "fastMode", value: true }]); | |
| 462 | }), | |
| 463 | ); | |
| 464 | ||
| 380 | 465 | it.effect("accepts a title seed in thread.turn.start", () => |
| 381 | 466 | Effect.gen(function* () { |
| 382 | 467 | const parsed = yield* decodeThreadTurnStartCommand({ |
packages/contracts/src/orchestration.ts+5-10| ... | ... | @@ -1,10 +1,5 @@ |
| 1 | 1 | import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | import { | |
| 3 | ClaudeModelOptions, | |
| 4 | CodexModelOptions, | |
| 5 | CursorModelOptions, | |
| 6 | OpenCodeModelOptions, | |
| 7 | } from "./model.ts"; | |
| 2 | import { ProviderOptionSelections } from "./model.ts"; | |
| 8 | 3 | import { RepositoryIdentity } from "./environment.ts"; |
| 9 | 4 | import { |
| 10 | 5 | ApprovalRequestId, |
| ... | ... | @@ -51,27 +46,27 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 51 | 46 | export const CodexModelSelection = Schema.Struct({ |
| 52 | 47 | provider: Schema.Literal("codex"), |
| 53 | 48 | model: TrimmedNonEmptyString, |
| 54 | options: Schema.optionalKey(CodexModelOptions), | |
| 49 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 55 | 50 | }); |
| 56 | 51 | export type CodexModelSelection = typeof CodexModelSelection.Type; |
| 57 | 52 | |
| 58 | 53 | export const ClaudeModelSelection = Schema.Struct({ |
| 59 | 54 | provider: Schema.Literal("claudeAgent"), |
| 60 | 55 | model: TrimmedNonEmptyString, |
| 61 | options: Schema.optionalKey(ClaudeModelOptions), | |
| 56 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 62 | 57 | }); |
| 63 | 58 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; |
| 64 | 59 | |
| 65 | 60 | export const CursorModelSelection = Schema.Struct({ |
| 66 | 61 | provider: Schema.Literal("cursor"), |
| 67 | 62 | model: TrimmedNonEmptyString, |
| 68 | options: Schema.optionalKey(CursorModelOptions), | |
| 63 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 69 | 64 | }); |
| 70 | 65 | export type CursorModelSelection = typeof CursorModelSelection.Type; |
| 71 | 66 | export const OpenCodeModelSelection = Schema.Struct({ |
| 72 | 67 | provider: Schema.Literal("opencode"), |
| 73 | 68 | model: TrimmedNonEmptyString, |
| 74 | options: Schema.optionalKey(OpenCodeModelOptions), | |
| 69 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 75 | 70 | }); |
| 76 | 71 | export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; |
| 77 | 72 |
packages/contracts/src/provider.test.ts+35-28| ... | ... | @@ -6,6 +6,13 @@ import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts" |
| 6 | 6 | const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); |
| 7 | 7 | const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); |
| 8 | 8 | |
| 9 | function getOptionValue( | |
| 10 | options: ReadonlyArray<{ id: string; value: unknown }> | undefined, | |
| 11 | id: string, | |
| 12 | ): unknown { | |
| 13 | return options?.find((option) => option.id === id)?.value; | |
| 14 | } | |
| 15 | ||
| 9 | 16 | describe("ProviderSessionStartInput", () => { |
| 10 | 17 | it("accepts codex-compatible payloads", () => { |
| 11 | 18 | const parsed = decodeProviderSessionStartInput({ |
| ... | ... | @@ -15,10 +22,10 @@ describe("ProviderSessionStartInput", () => { |
| 15 | 22 | modelSelection: { |
| 16 | 23 | provider: "codex", |
| 17 | 24 | model: "gpt-5.3-codex", |
| 18 | options: { | |
| 19 | reasoningEffort: "high", | |
| 20 | fastMode: true, | |
| 21 | }, | |
| 25 | options: [ | |
| 26 | { id: "reasoningEffort", value: "high" }, | |
| 27 | { id: "fastMode", value: true }, | |
| 28 | ], | |
| 22 | 29 | }, |
| 23 | 30 | runtimeMode: "full-access", |
| 24 | 31 | }); |
| ... | ... | @@ -28,8 +35,8 @@ describe("ProviderSessionStartInput", () => { |
| 28 | 35 | if (parsed.modelSelection?.provider !== "codex") { |
| 29 | 36 | throw new Error("Expected codex modelSelection"); |
| 30 | 37 | } |
| 31 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); | |
| 32 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | |
| 38 | expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("high"); | |
| 39 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | |
| 33 | 40 | }); |
| 34 | 41 | |
| 35 | 42 | it("rejects payloads without runtime mode", () => { |
| ... | ... | @@ -49,11 +56,11 @@ describe("ProviderSessionStartInput", () => { |
| 49 | 56 | modelSelection: { |
| 50 | 57 | provider: "claudeAgent", |
| 51 | 58 | model: "claude-sonnet-4-6", |
| 52 | options: { | |
| 53 | thinking: true, | |
| 54 | effort: "max", | |
| 55 | fastMode: true, | |
| 56 | }, | |
| 59 | options: [ | |
| 60 | { id: "thinking", value: true }, | |
| 61 | { id: "effort", value: "max" }, | |
| 62 | { id: "fastMode", value: true }, | |
| 63 | ], | |
| 57 | 64 | }, |
| 58 | 65 | runtimeMode: "full-access", |
| 59 | 66 | }); |
| ... | ... | @@ -63,9 +70,9 @@ describe("ProviderSessionStartInput", () => { |
| 63 | 70 | if (parsed.modelSelection?.provider !== "claudeAgent") { |
| 64 | 71 | throw new Error("Expected claude modelSelection"); |
| 65 | 72 | } |
| 66 | expect(parsed.modelSelection.options?.thinking).toBe(true); | |
| 67 | expect(parsed.modelSelection.options?.effort).toBe("max"); | |
| 68 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | |
| 73 | expect(getOptionValue(parsed.modelSelection.options, "thinking")).toBe(true); | |
| 74 | expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("max"); | |
| 75 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | |
| 69 | 76 | expect(parsed.runtimeMode).toBe("full-access"); |
| 70 | 77 | }); |
| 71 | 78 | |
| ... | ... | @@ -78,14 +85,14 @@ describe("ProviderSessionStartInput", () => { |
| 78 | 85 | modelSelection: { |
| 79 | 86 | provider: "cursor", |
| 80 | 87 | model: "composer-2", |
| 81 | options: { fastMode: true }, | |
| 88 | options: [{ id: "fastMode", value: true }], | |
| 82 | 89 | }, |
| 83 | 90 | }); |
| 84 | 91 | expect(parsed.provider).toBe("cursor"); |
| 85 | 92 | expect(parsed.modelSelection?.provider).toBe("cursor"); |
| 86 | 93 | expect(parsed.modelSelection?.model).toBe("composer-2"); |
| 87 | 94 | if (parsed.modelSelection?.provider === "cursor") { |
| 88 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | |
| 95 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | |
| 89 | 96 | } |
| 90 | 97 | }); |
| 91 | 98 | }); |
| ... | ... | @@ -97,10 +104,10 @@ describe("ProviderSendTurnInput", () => { |
| 97 | 104 | modelSelection: { |
| 98 | 105 | provider: "codex", |
| 99 | 106 | model: "gpt-5.3-codex", |
| 100 | options: { | |
| 101 | reasoningEffort: "xhigh", | |
| 102 | fastMode: true, | |
| 103 | }, | |
| 107 | options: [ | |
| 108 | { id: "reasoningEffort", value: "xhigh" }, | |
| 109 | { id: "fastMode", value: true }, | |
| 110 | ], | |
| 104 | 111 | }, |
| 105 | 112 | }); |
| 106 | 113 | |
| ... | ... | @@ -109,8 +116,8 @@ describe("ProviderSendTurnInput", () => { |
| 109 | 116 | if (parsed.modelSelection?.provider !== "codex") { |
| 110 | 117 | throw new Error("Expected codex modelSelection"); |
| 111 | 118 | } |
| 112 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); | |
| 113 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | |
| 119 | expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("xhigh"); | |
| 120 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | |
| 114 | 121 | }); |
| 115 | 122 | |
| 116 | 123 | it("accepts claude modelSelection including ultrathink", () => { |
| ... | ... | @@ -119,10 +126,10 @@ describe("ProviderSendTurnInput", () => { |
| 119 | 126 | modelSelection: { |
| 120 | 127 | provider: "claudeAgent", |
| 121 | 128 | model: "claude-sonnet-4-6", |
| 122 | options: { | |
| 123 | effort: "ultrathink", | |
| 124 | fastMode: true, | |
| 125 | }, | |
| 129 | options: [ | |
| 130 | { id: "effort", value: "ultrathink" }, | |
| 131 | { id: "fastMode", value: true }, | |
| 132 | ], | |
| 126 | 133 | }, |
| 127 | 134 | }); |
| 128 | 135 | |
| ... | ... | @@ -130,7 +137,7 @@ describe("ProviderSendTurnInput", () => { |
| 130 | 137 | if (parsed.modelSelection?.provider !== "claudeAgent") { |
| 131 | 138 | throw new Error("Expected claude modelSelection"); |
| 132 | 139 | } |
| 133 | expect(parsed.modelSelection.options?.effort).toBe("ultrathink"); | |
| 134 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | |
| 140 | expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("ultrathink"); | |
| 141 | expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); | |
| 135 | 142 | }); |
| 136 | 143 | }); |
packages/contracts/src/server.ts+3| ... | ... | @@ -85,6 +85,9 @@ export type ServerProviderSkill = typeof ServerProviderSkill.Type; |
| 85 | 85 | |
| 86 | 86 | export const ServerProvider = Schema.Struct({ |
| 87 | 87 | provider: ProviderKind, |
| 88 | displayName: Schema.optional(TrimmedNonEmptyString), | |
| 89 | badgeLabel: Schema.optional(TrimmedNonEmptyString), | |
| 90 | showInteractionModeToggle: Schema.optional(Schema.Boolean), | |
| 88 | 91 | enabled: Schema.Boolean, |
| 89 | 92 | installed: Schema.Boolean, |
| 90 | 93 | version: Schema.NullOr(TrimmedNonEmptyString), |
packages/contracts/src/settings.ts+5-32| ... | ... | @@ -3,11 +3,8 @@ import * as Schema from "effect/Schema"; |
| 3 | 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| 5 | 5 | import { |
| 6 | ClaudeModelOptions, | |
| 7 | CodexModelOptions, | |
| 8 | CursorModelOptions, | |
| 9 | 6 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 10 | OpenCodeModelOptions, | |
| 7 | ProviderOptionSelections, | |
| 11 | 8 | } from "./model.ts"; |
| 12 | 9 | import { ModelSelection, ProviderKind } from "./orchestration.ts"; |
| 13 | 10 | |
| ... | ... | @@ -170,50 +167,26 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { |
| 170 | 167 | |
| 171 | 168 | // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── |
| 172 | 169 | |
| 173 | const CodexModelOptionsPatch = Schema.Struct({ | |
| 174 | reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), | |
| 175 | fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), | |
| 176 | }); | |
| 177 | ||
| 178 | const ClaudeModelOptionsPatch = Schema.Struct({ | |
| 179 | thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), | |
| 180 | effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), | |
| 181 | fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), | |
| 182 | contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), | |
| 183 | }); | |
| 184 | ||
| 185 | const CursorModelOptionsPatch = Schema.Struct({ | |
| 186 | reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), | |
| 187 | fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), | |
| 188 | thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), | |
| 189 | contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), | |
| 190 | }); | |
| 191 | ||
| 192 | const OpenCodeModelOptionsPatch = Schema.Struct({ | |
| 193 | variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), | |
| 194 | agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), | |
| 195 | }); | |
| 196 | ||
| 197 | 170 | const ModelSelectionPatch = Schema.Union([ |
| 198 | 171 | Schema.Struct({ |
| 199 | 172 | provider: Schema.optionalKey(Schema.Literal("codex")), |
| 200 | 173 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 201 | options: Schema.optionalKey(CodexModelOptionsPatch), | |
| 174 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 202 | 175 | }), |
| 203 | 176 | Schema.Struct({ |
| 204 | 177 | provider: Schema.optionalKey(Schema.Literal("claudeAgent")), |
| 205 | 178 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 206 | options: Schema.optionalKey(ClaudeModelOptionsPatch), | |
| 179 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 207 | 180 | }), |
| 208 | 181 | Schema.Struct({ |
| 209 | 182 | provider: Schema.optionalKey(Schema.Literal("cursor")), |
| 210 | 183 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 211 | options: Schema.optionalKey(CursorModelOptionsPatch), | |
| 184 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 212 | 185 | }), |
| 213 | 186 | Schema.Struct({ |
| 214 | 187 | provider: Schema.optionalKey(Schema.Literal("opencode")), |
| 215 | 188 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 216 | options: Schema.optionalKey(OpenCodeModelOptionsPatch), | |
| 189 | options: Schema.optionalKey(ProviderOptionSelections), | |
| 217 | 190 | }), |
| 218 | 191 | ]); |
| 219 | 192 |
packages/shared/src/model.test.ts+124-158| ... | ... | @@ -3,46 +3,67 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/cont |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | applyClaudePromptEffortPrefix, |
| 6 | getDefaultContextWindow, | |
| 7 | getDefaultEffort, | |
| 8 | hasContextWindowOption, | |
| 9 | hasEffortLevel, | |
| 6 | buildProviderOptionSelectionsFromDescriptors, | |
| 7 | createModelCapabilities, | |
| 8 | createModelSelection, | |
| 9 | getModelSelectionBooleanOptionValue, | |
| 10 | getModelSelectionStringOptionValue, | |
| 11 | getProviderOptionDescriptors, | |
| 12 | getProviderOptionBooleanSelectionValue, | |
| 13 | getProviderOptionStringSelectionValue, | |
| 10 | 14 | isClaudeUltrathinkPrompt, |
| 11 | normalizeClaudeModelOptionsWithCapabilities, | |
| 12 | normalizeCodexModelOptionsWithCapabilities, | |
| 13 | 15 | normalizeModelSlug, |
| 14 | resolveContextWindow, | |
| 15 | resolveEffort, | |
| 16 | 16 | resolveModelSlugForProvider, |
| 17 | 17 | resolveSelectableModel, |
| 18 | 18 | trimOrNull, |
| 19 | 19 | } from "./model.ts"; |
| 20 | 20 | |
| 21 | const codexCaps: ModelCapabilities = { | |
| 22 | reasoningEffortLevels: [ | |
| 23 | { value: "xhigh", label: "Extra High" }, | |
| 24 | { value: "high", label: "High", isDefault: true }, | |
| 21 | const codexCaps: ModelCapabilities = createModelCapabilities({ | |
| 22 | optionDescriptors: [ | |
| 23 | { | |
| 24 | id: "reasoningEffort", | |
| 25 | label: "Reasoning", | |
| 26 | type: "select", | |
| 27 | options: [ | |
| 28 | { id: "xhigh", label: "Extra High" }, | |
| 29 | { id: "high", label: "High", isDefault: true }, | |
| 30 | ], | |
| 31 | currentValue: "high", | |
| 32 | }, | |
| 33 | { | |
| 34 | id: "fastMode", | |
| 35 | label: "Fast Mode", | |
| 36 | type: "boolean", | |
| 37 | }, | |
| 25 | 38 | ], |
| 26 | supportsFastMode: true, | |
| 27 | supportsThinkingToggle: false, | |
| 28 | contextWindowOptions: [], | |
| 29 | promptInjectedEffortLevels: [], | |
| 30 | }; | |
| 31 | ||
| 32 | const claudeCaps: ModelCapabilities = { | |
| 33 | reasoningEffortLevels: [ | |
| 34 | { value: "medium", label: "Medium" }, | |
| 35 | { value: "high", label: "High", isDefault: true }, | |
| 36 | { value: "ultrathink", label: "Ultrathink" }, | |
| 37 | ], | |
| 38 | supportsFastMode: false, | |
| 39 | supportsThinkingToggle: false, | |
| 40 | contextWindowOptions: [ | |
| 41 | { value: "200k", label: "200k" }, | |
| 42 | { value: "1m", label: "1M", isDefault: true }, | |
| 39 | }); | |
| 40 | ||
| 41 | const claudeCaps: ModelCapabilities = createModelCapabilities({ | |
| 42 | optionDescriptors: [ | |
| 43 | { | |
| 44 | id: "effort", | |
| 45 | label: "Reasoning", | |
| 46 | type: "select", | |
| 47 | options: [ | |
| 48 | { id: "medium", label: "Medium" }, | |
| 49 | { id: "high", label: "High", isDefault: true }, | |
| 50 | { id: "ultrathink", label: "Ultrathink" }, | |
| 51 | ], | |
| 52 | currentValue: "high", | |
| 53 | promptInjectedValues: ["ultrathink"], | |
| 54 | }, | |
| 55 | { | |
| 56 | id: "contextWindow", | |
| 57 | label: "Context Window", | |
| 58 | type: "select", | |
| 59 | options: [ | |
| 60 | { id: "200k", label: "200k" }, | |
| 61 | { id: "1m", label: "1M", isDefault: true }, | |
| 62 | ], | |
| 63 | currentValue: "1m", | |
| 64 | }, | |
| 43 | 65 | ], |
| 44 | promptInjectedEffortLevels: ["ultrathink"], | |
| 45 | }; | |
| 66 | }); | |
| 46 | 67 | |
| 47 | 68 | describe("normalizeModelSlug", () => { |
| 48 | 69 | it("maps known aliases to canonical slugs", () => { |
| ... | ... | @@ -86,54 +107,6 @@ describe("resolveSelectableModel", () => { |
| 86 | 107 | }); |
| 87 | 108 | }); |
| 88 | 109 | |
| 89 | describe("capability helpers", () => { | |
| 90 | it("reads default efforts", () => { | |
| 91 | expect(getDefaultEffort(codexCaps)).toBe("high"); | |
| 92 | expect(getDefaultEffort(claudeCaps)).toBe("high"); | |
| 93 | }); | |
| 94 | ||
| 95 | it("checks effort support", () => { | |
| 96 | expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); | |
| 97 | expect(hasEffortLevel(codexCaps, "max")).toBe(false); | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("resolveEffort", () => { | |
| 102 | it("returns the explicit value when supported and not prompt-injected", () => { | |
| 103 | expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); | |
| 104 | expect(resolveEffort(codexCaps, "high")).toBe("high"); | |
| 105 | expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("falls back to default when value is unsupported", () => { | |
| 109 | expect(resolveEffort(codexCaps, "bogus")).toBe("high"); | |
| 110 | expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); | |
| 111 | }); | |
| 112 | ||
| 113 | it("returns the default when no value is provided", () => { | |
| 114 | expect(resolveEffort(codexCaps, undefined)).toBe("high"); | |
| 115 | expect(resolveEffort(codexCaps, null)).toBe("high"); | |
| 116 | expect(resolveEffort(codexCaps, "")).toBe("high"); | |
| 117 | expect(resolveEffort(codexCaps, " ")).toBe("high"); | |
| 118 | }); | |
| 119 | ||
| 120 | it("excludes prompt-injected efforts and falls back to default", () => { | |
| 121 | expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); | |
| 122 | }); | |
| 123 | ||
| 124 | it("returns undefined for models with no effort levels", () => { | |
| 125 | const noCaps: ModelCapabilities = { | |
| 126 | reasoningEffortLevels: [], | |
| 127 | supportsFastMode: false, | |
| 128 | supportsThinkingToggle: false, | |
| 129 | contextWindowOptions: [], | |
| 130 | promptInjectedEffortLevels: [], | |
| 131 | }; | |
| 132 | expect(resolveEffort(noCaps, undefined)).toBeUndefined(); | |
| 133 | expect(resolveEffort(noCaps, "high")).toBeUndefined(); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | 110 | describe("misc helpers", () => { |
| 138 | 111 | it("detects ultrathink prompts", () => { |
| 139 | 112 | expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); |
| ... | ... | @@ -156,95 +129,88 @@ describe("misc helpers", () => { |
| 156 | 129 | }); |
| 157 | 130 | }); |
| 158 | 131 | |
| 159 | describe("context window helpers", () => { | |
| 160 | it("reads default context window", () => { | |
| 161 | expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); | |
| 162 | }); | |
| 163 | ||
| 164 | it("returns null for models without context window options", () => { | |
| 165 | expect(getDefaultContextWindow(codexCaps)).toBeNull(); | |
| 166 | }); | |
| 167 | ||
| 168 | it("checks context window support", () => { | |
| 169 | expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); | |
| 170 | expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); | |
| 171 | expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); | |
| 172 | expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); | |
| 173 | }); | |
| 174 | }); | |
| 175 | ||
| 176 | describe("resolveContextWindow", () => { | |
| 177 | it("returns the explicit value when supported", () => { | |
| 178 | expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); | |
| 179 | expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); | |
| 180 | }); | |
| 181 | ||
| 182 | it("falls back to default when value is unsupported", () => { | |
| 183 | expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); | |
| 184 | }); | |
| 185 | ||
| 186 | it("returns the default when no value is provided", () => { | |
| 187 | expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); | |
| 188 | expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); | |
| 189 | expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); | |
| 190 | }); | |
| 191 | ||
| 192 | it("returns undefined for models with no context window options", () => { | |
| 193 | expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); | |
| 194 | expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); | |
| 195 | }); | |
| 196 | }); | |
| 197 | ||
| 198 | describe("normalize*ModelOptionsWithCapabilities", () => { | |
| 199 | it("preserves explicit false codex fast mode", () => { | |
| 132 | describe("descriptor helpers", () => { | |
| 133 | it("applies selection values to capability descriptors", () => { | |
| 200 | 134 | expect( |
| 201 | normalizeCodexModelOptionsWithCapabilities(codexCaps, { | |
| 202 | reasoningEffort: "high", | |
| 203 | fastMode: false, | |
| 135 | getProviderOptionDescriptors({ | |
| 136 | caps: claudeCaps, | |
| 137 | selections: [ | |
| 138 | { id: "effort", value: "medium" }, | |
| 139 | { id: "contextWindow", value: "200k" }, | |
| 140 | ], | |
| 204 | 141 | }), |
| 205 | ).toEqual({ | |
| 206 | reasoningEffort: "high", | |
| 207 | fastMode: false, | |
| 142 | ).toEqual([ | |
| 143 | { | |
| 144 | id: "effort", | |
| 145 | label: "Reasoning", | |
| 146 | type: "select", | |
| 147 | options: [ | |
| 148 | { id: "medium", label: "Medium" }, | |
| 149 | { id: "high", label: "High", isDefault: true }, | |
| 150 | { id: "ultrathink", label: "Ultrathink" }, | |
| 151 | ], | |
| 152 | currentValue: "medium", | |
| 153 | promptInjectedValues: ["ultrathink"], | |
| 154 | }, | |
| 155 | { | |
| 156 | id: "contextWindow", | |
| 157 | label: "Context Window", | |
| 158 | type: "select", | |
| 159 | options: [ | |
| 160 | { id: "200k", label: "200k" }, | |
| 161 | { id: "1m", label: "1M", isDefault: true }, | |
| 162 | ], | |
| 163 | currentValue: "200k", | |
| 164 | }, | |
| 165 | ]); | |
| 166 | }); | |
| 167 | ||
| 168 | it("builds wire-format option selections from descriptors", () => { | |
| 169 | const descriptors = getProviderOptionDescriptors({ | |
| 170 | caps: codexCaps, | |
| 171 | selections: [ | |
| 172 | { id: "reasoningEffort", value: "high" }, | |
| 173 | { id: "fastMode", value: true }, | |
| 174 | ], | |
| 208 | 175 | }); |
| 176 | ||
| 177 | expect(buildProviderOptionSelectionsFromDescriptors(descriptors)).toEqual([ | |
| 178 | { id: "reasoningEffort", value: "high" }, | |
| 179 | { id: "fastMode", value: true }, | |
| 180 | ]); | |
| 209 | 181 | }); |
| 210 | 182 | |
| 211 | it("preserves the default Claude context window explicitly", () => { | |
| 183 | it("stores option selection arrays in model selections", () => { | |
| 212 | 184 | expect( |
| 213 | normalizeClaudeModelOptionsWithCapabilities( | |
| 214 | { | |
| 215 | ...claudeCaps, | |
| 216 | contextWindowOptions: [ | |
| 217 | { value: "200k", label: "200k", isDefault: true }, | |
| 218 | { value: "1m", label: "1M" }, | |
| 219 | ], | |
| 220 | }, | |
| 221 | { | |
| 222 | effort: "high", | |
| 223 | contextWindow: "200k", | |
| 224 | }, | |
| 225 | ), | |
| 185 | createModelSelection("codex", "gpt-5.4", [ | |
| 186 | { id: "reasoningEffort", value: "high" }, | |
| 187 | { id: "fastMode", value: true }, | |
| 188 | ]), | |
| 226 | 189 | ).toEqual({ |
| 227 | effort: "high", | |
| 228 | contextWindow: "200k", | |
| 190 | provider: "codex", | |
| 191 | model: "gpt-5.4", | |
| 192 | options: [ | |
| 193 | { id: "reasoningEffort", value: "high" }, | |
| 194 | { id: "fastMode", value: true }, | |
| 195 | ], | |
| 229 | 196 | }); |
| 230 | 197 | }); |
| 231 | 198 | |
| 232 | it("omits unsupported Claude context window options", () => { | |
| 199 | it("reads typed option selection values", () => { | |
| 200 | const selection = createModelSelection("codex", "gpt-5.4", [ | |
| 201 | { id: "reasoningEffort", value: "high" }, | |
| 202 | { id: "fastMode", value: true }, | |
| 203 | ]); | |
| 204 | ||
| 205 | expect(getProviderOptionStringSelectionValue(selection.options, "reasoningEffort")).toBe( | |
| 206 | "high", | |
| 207 | ); | |
| 208 | expect(getProviderOptionStringSelectionValue(selection.options, "fastMode")).toBeUndefined(); | |
| 209 | expect(getProviderOptionBooleanSelectionValue(selection.options, "fastMode")).toBe(true); | |
| 233 | 210 | expect( |
| 234 | normalizeClaudeModelOptionsWithCapabilities( | |
| 235 | { | |
| 236 | ...claudeCaps, | |
| 237 | reasoningEffortLevels: [], | |
| 238 | supportsThinkingToggle: true, | |
| 239 | contextWindowOptions: [], | |
| 240 | }, | |
| 241 | { | |
| 242 | thinking: true, | |
| 243 | contextWindow: "1m", | |
| 244 | }, | |
| 245 | ), | |
| 246 | ).toEqual({ | |
| 247 | thinking: true, | |
| 248 | }); | |
| 211 | getProviderOptionBooleanSelectionValue(selection.options, "reasoningEffort"), | |
| 212 | ).toBeUndefined(); | |
| 213 | expect(getModelSelectionStringOptionValue(selection, "reasoningEffort")).toBe("high"); | |
| 214 | expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true); | |
| 249 | 215 | }); |
| 250 | 216 | }); |
packages/shared/src/model.ts+216-153| ... | ... | @@ -1,15 +1,11 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 3 | 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 4 | type ClaudeAgentEffort, | |
| 5 | type ClaudeModelOptions, | |
| 6 | type CodexModelOptions, | |
| 7 | type CursorModelOptions, | |
| 8 | 4 | type ModelCapabilities, |
| 9 | 5 | type ModelSelection, |
| 10 | type OpenCodeModelOptions, | |
| 6 | type ProviderOptionDescriptor, | |
| 7 | type ProviderOptionSelection, | |
| 11 | 8 | type ProviderKind, |
| 12 | type ProviderModelOptions, | |
| 13 | 9 | } from "@t3tools/contracts"; |
| 14 | 10 | |
| 15 | 11 | export interface SelectableModelOption { |
| ... | ... | @@ -17,157 +13,215 @@ export interface SelectableModelOption { |
| 17 | 13 | name: string; |
| 18 | 14 | } |
| 19 | 15 | |
| 20 | /** Check whether a capabilities object includes a given effort value. */ | |
| 21 | export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { | |
| 22 | return caps.reasoningEffortLevels.some((l) => l.value === value); | |
| 16 | export function createModelCapabilities(input: { | |
| 17 | optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; | |
| 18 | }): ModelCapabilities { | |
| 19 | return { | |
| 20 | optionDescriptors: input.optionDescriptors.map(cloneDescriptor), | |
| 21 | }; | |
| 23 | 22 | } |
| 24 | 23 | |
| 25 | /** Return the default effort value for a capabilities object, or null if none. */ | |
| 26 | export function getDefaultEffort(caps: ModelCapabilities): string | null { | |
| 27 | return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; | |
| 24 | function getRawSelectionValueById( | |
| 25 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 26 | id: string, | |
| 27 | ): string | boolean | undefined { | |
| 28 | const selection = selections?.find((candidate) => candidate.id === id); | |
| 29 | return selection?.value; | |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | /** | |
| 31 | * Resolve a raw effort option against capabilities. | |
| 32 | * | |
| 33 | * Returns the explicit supported value when present and not prompt-injected, | |
| 34 | * otherwise the model default. Returns `undefined` when the model exposes no | |
| 35 | * effort levels. | |
| 36 | */ | |
| 37 | export function resolveEffort( | |
| 38 | caps: ModelCapabilities, | |
| 32 | export function getProviderOptionSelectionValue( | |
| 33 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 34 | id: string, | |
| 35 | ): string | boolean | undefined { | |
| 36 | return getRawSelectionValueById(selections, id); | |
| 37 | } | |
| 38 | ||
| 39 | export function getProviderOptionStringSelectionValue( | |
| 40 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 41 | id: string, | |
| 42 | ): string | undefined { | |
| 43 | const value = getProviderOptionSelectionValue(selections, id); | |
| 44 | return typeof value === "string" ? value : undefined; | |
| 45 | } | |
| 46 | ||
| 47 | export function getProviderOptionBooleanSelectionValue( | |
| 48 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, | |
| 49 | id: string, | |
| 50 | ): boolean | undefined { | |
| 51 | const value = getProviderOptionSelectionValue(selections, id); | |
| 52 | return typeof value === "boolean" ? value : undefined; | |
| 53 | } | |
| 54 | ||
| 55 | export function getModelSelectionOptionValue( | |
| 56 | modelSelection: ModelSelection | null | undefined, | |
| 57 | id: string, | |
| 58 | ): string | boolean | undefined { | |
| 59 | return getProviderOptionSelectionValue(modelSelection?.options, id); | |
| 60 | } | |
| 61 | ||
| 62 | export function getModelSelectionStringOptionValue( | |
| 63 | modelSelection: ModelSelection | null | undefined, | |
| 64 | id: string, | |
| 65 | ): string | undefined { | |
| 66 | return getProviderOptionStringSelectionValue(modelSelection?.options, id); | |
| 67 | } | |
| 68 | ||
| 69 | export function getModelSelectionBooleanOptionValue( | |
| 70 | modelSelection: ModelSelection | null | undefined, | |
| 71 | id: string, | |
| 72 | ): boolean | undefined { | |
| 73 | return getProviderOptionBooleanSelectionValue(modelSelection?.options, id); | |
| 74 | } | |
| 75 | ||
| 76 | function resolveDescriptorChoiceValue( | |
| 77 | descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>, | |
| 39 | 78 | raw: string | null | undefined, |
| 40 | 79 | ): string | undefined { |
| 41 | const defaultValue = getDefaultEffort(caps); | |
| 42 | const trimmed = typeof raw === "string" ? raw.trim() : null; | |
| 80 | const trimmed = trimOrNull(raw); | |
| 81 | if (!trimmed) { | |
| 82 | return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; | |
| 83 | } | |
| 84 | if (descriptor.options.length === 0) { | |
| 85 | return trimmed; | |
| 86 | } | |
| 43 | 87 | if ( |
| 44 | trimmed && | |
| 45 | !caps.promptInjectedEffortLevels.includes(trimmed) && | |
| 46 | hasEffortLevel(caps, trimmed) | |
| 88 | descriptor.promptInjectedValues?.includes(trimmed) && | |
| 89 | descriptor.options.some((option) => option.id === trimmed) | |
| 47 | 90 | ) { |
| 91 | return descriptor.options.find((option) => option.isDefault)?.id; | |
| 92 | } | |
| 93 | if (descriptor.options.some((option) => option.id === trimmed)) { | |
| 48 | 94 | return trimmed; |
| 49 | 95 | } |
| 50 | return defaultValue ?? undefined; | |
| 96 | return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; | |
| 51 | 97 | } |
| 52 | 98 | |
| 53 | /** Check whether a capabilities object includes a given context window value. */ | |
| 54 | export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { | |
| 55 | return caps.contextWindowOptions.some((o) => o.value === value); | |
| 99 | function cloneDescriptor(descriptor: ProviderOptionDescriptor): ProviderOptionDescriptor { | |
| 100 | return descriptor.type === "select" | |
| 101 | ? { | |
| 102 | ...descriptor, | |
| 103 | options: [...descriptor.options], | |
| 104 | ...(descriptor.promptInjectedValues | |
| 105 | ? { promptInjectedValues: [...descriptor.promptInjectedValues] } | |
| 106 | : {}), | |
| 107 | } | |
| 108 | : { ...descriptor }; | |
| 56 | 109 | } |
| 57 | 110 | |
| 58 | /** Return the default context window value, or `null` if none is defined. */ | |
| 59 | export function getDefaultContextWindow(caps: ModelCapabilities): string | null { | |
| 60 | return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Resolve a raw `contextWindow` option against capabilities. | |
| 65 | * | |
| 66 | * Returns the explicit supported value when present, otherwise the model | |
| 67 | * default. Returns `undefined` when the model exposes no context window options. | |
| 68 | */ | |
| 69 | export function resolveContextWindow( | |
| 70 | caps: ModelCapabilities, | |
| 71 | raw: string | null | undefined, | |
| 72 | ): string | undefined { | |
| 73 | const defaultValue = getDefaultContextWindow(caps); | |
| 74 | if (!raw) return defaultValue ?? undefined; | |
| 75 | return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); | |
| 111 | function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection { | |
| 112 | return { ...selection }; | |
| 76 | 113 | } |
| 77 | 114 | |
| 78 | export function normalizeCodexModelOptionsWithCapabilities( | |
| 79 | caps: ModelCapabilities, | |
| 80 | modelOptions: CodexModelOptions | null | undefined, | |
| 81 | ): CodexModelOptions | undefined { | |
| 82 | const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); | |
| 83 | const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; | |
| 84 | const nextOptions: CodexModelOptions = { | |
| 85 | ...(reasoningEffort | |
| 86 | ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } | |
| 87 | : {}), | |
| 88 | ...(fastMode !== undefined ? { fastMode } : {}), | |
| 115 | function withDescriptorCurrentValue( | |
| 116 | descriptor: ProviderOptionDescriptor, | |
| 117 | rawCurrentValue: string | boolean | undefined, | |
| 118 | ): ProviderOptionDescriptor { | |
| 119 | if (descriptor.type === "boolean") { | |
| 120 | if (typeof rawCurrentValue === "boolean") { | |
| 121 | return { | |
| 122 | ...descriptor, | |
| 123 | currentValue: rawCurrentValue, | |
| 124 | }; | |
| 125 | } | |
| 126 | return descriptor; | |
| 127 | } | |
| 128 | const currentValue = | |
| 129 | typeof rawCurrentValue === "string" | |
| 130 | ? resolveDescriptorChoiceValue(descriptor, rawCurrentValue) | |
| 131 | : resolveDescriptorChoiceValue(descriptor, descriptor.currentValue); | |
| 132 | if (!currentValue) { | |
| 133 | const { currentValue: _unusedCurrentValue, ...rest } = descriptor; | |
| 134 | return rest; | |
| 135 | } | |
| 136 | return { | |
| 137 | ...descriptor, | |
| 138 | currentValue, | |
| 89 | 139 | }; |
| 90 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 91 | 140 | } |
| 92 | 141 | |
| 93 | export function normalizeClaudeModelOptionsWithCapabilities( | |
| 94 | caps: ModelCapabilities, | |
| 95 | modelOptions: ClaudeModelOptions | null | undefined, | |
| 96 | ): ClaudeModelOptions | undefined { | |
| 97 | const effort = resolveEffort(caps, modelOptions?.effort); | |
| 98 | const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; | |
| 99 | const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; | |
| 100 | const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); | |
| 101 | const nextOptions: ClaudeModelOptions = { | |
| 102 | ...(thinking !== undefined ? { thinking } : {}), | |
| 103 | ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), | |
| 104 | ...(fastMode !== undefined ? { fastMode } : {}), | |
| 105 | ...(contextWindow !== undefined ? { contextWindow } : {}), | |
| 106 | }; | |
| 107 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 142 | export function getProviderOptionDescriptors(input: { | |
| 143 | caps: ModelCapabilities; | |
| 144 | selections?: ReadonlyArray<ProviderOptionSelection> | null | undefined; | |
| 145 | }): ReadonlyArray<ProviderOptionDescriptor> { | |
| 146 | const { caps, selections } = input; | |
| 147 | const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); | |
| 148 | ||
| 149 | return baseDescriptors.map((descriptor) => | |
| 150 | withDescriptorCurrentValue( | |
| 151 | descriptor, | |
| 152 | getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue, | |
| 153 | ), | |
| 154 | ); | |
| 108 | 155 | } |
| 109 | 156 | |
| 110 | export function normalizeCursorModelOptionsWithCapabilities( | |
| 111 | caps: ModelCapabilities, | |
| 112 | modelOptions: CursorModelOptions | null | undefined, | |
| 113 | ): CursorModelOptions | undefined { | |
| 114 | const reasoning = resolveEffort(caps, modelOptions?.reasoning); | |
| 115 | const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; | |
| 116 | const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; | |
| 117 | const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); | |
| 118 | const nextOptions: CursorModelOptions = { | |
| 119 | ...(reasoning ? { reasoning: reasoning as CursorModelOptions["reasoning"] } : {}), | |
| 120 | ...(fastMode !== undefined ? { fastMode } : {}), | |
| 121 | ...(thinking !== undefined ? { thinking } : {}), | |
| 122 | ...(contextWindow !== undefined ? { contextWindow } : {}), | |
| 123 | }; | |
| 124 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 157 | export function getProviderOptionCurrentValue( | |
| 158 | descriptor: ProviderOptionDescriptor | null | undefined, | |
| 159 | ): string | boolean | undefined { | |
| 160 | if (!descriptor) { | |
| 161 | return undefined; | |
| 162 | } | |
| 163 | if (descriptor.type === "boolean") { | |
| 164 | return descriptor.currentValue; | |
| 165 | } | |
| 166 | if (descriptor.currentValue) { | |
| 167 | return descriptor.currentValue; | |
| 168 | } | |
| 169 | return descriptor.options.find((option) => option.isDefault)?.id; | |
| 125 | 170 | } |
| 126 | 171 | |
| 127 | function resolveLabeledOption( | |
| 128 | options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, | |
| 129 | raw: string | null | undefined, | |
| 172 | export function getProviderOptionCurrentLabel( | |
| 173 | descriptor: ProviderOptionDescriptor | null | undefined, | |
| 130 | 174 | ): string | undefined { |
| 131 | if (!options || options.length === 0) { | |
| 132 | return raw ?? undefined; | |
| 175 | if (!descriptor) { | |
| 176 | return undefined; | |
| 177 | } | |
| 178 | if (descriptor.type === "boolean") { | |
| 179 | return typeof descriptor.currentValue === "boolean" | |
| 180 | ? descriptor.currentValue | |
| 181 | ? "On" | |
| 182 | : "Off" | |
| 183 | : undefined; | |
| 133 | 184 | } |
| 134 | if (raw && options.some((option) => option.value === raw)) { | |
| 135 | return raw; | |
| 185 | const currentValue = getProviderOptionCurrentValue(descriptor); | |
| 186 | if (typeof currentValue !== "string") { | |
| 187 | return undefined; | |
| 136 | 188 | } |
| 137 | return options.find((option) => option.isDefault)?.value ?? options[0]?.value; | |
| 189 | return descriptor.options.find((option) => option.id === currentValue)?.label; | |
| 138 | 190 | } |
| 139 | 191 | |
| 140 | export function normalizeOpenCodeModelOptionsWithCapabilities( | |
| 141 | caps: ModelCapabilities, | |
| 142 | modelOptions: OpenCodeModelOptions | null | undefined, | |
| 143 | ): OpenCodeModelOptions | undefined { | |
| 144 | const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); | |
| 145 | const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); | |
| 146 | const nextOptions: OpenCodeModelOptions = { | |
| 147 | ...(variant ? { variant } : {}), | |
| 148 | ...(agent ? { agent } : {}), | |
| 149 | }; | |
| 150 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 192 | export function buildProviderOptionSelectionsFromDescriptors( | |
| 193 | descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined, | |
| 194 | ): Array<ProviderOptionSelection> | undefined { | |
| 195 | if (!descriptors || descriptors.length === 0) { | |
| 196 | return undefined; | |
| 197 | } | |
| 198 | ||
| 199 | const nextSelections: Array<ProviderOptionSelection> = []; | |
| 200 | ||
| 201 | for (const descriptor of descriptors) { | |
| 202 | const value = getProviderOptionCurrentValue(descriptor); | |
| 203 | if (typeof value === "string" || typeof value === "boolean") { | |
| 204 | nextSelections.push({ id: descriptor.id, value }); | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | return nextSelections.length > 0 ? nextSelections : undefined; | |
| 151 | 209 | } |
| 152 | 210 | |
| 153 | export function normalizeProviderModelOptionsWithCapabilities( | |
| 154 | provider: ProviderKind, | |
| 155 | caps: ModelCapabilities, | |
| 156 | modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, | |
| 157 | ): ProviderModelOptions[ProviderKind] | undefined { | |
| 158 | switch (provider) { | |
| 159 | case "codex": | |
| 160 | return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); | |
| 161 | case "claudeAgent": | |
| 162 | return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); | |
| 163 | case "cursor": | |
| 164 | return normalizeCursorModelOptionsWithCapabilities(caps, modelOptions as CursorModelOptions); | |
| 165 | case "opencode": | |
| 166 | return normalizeOpenCodeModelOptionsWithCapabilities( | |
| 167 | caps, | |
| 168 | modelOptions as OpenCodeModelOptions, | |
| 169 | ); | |
| 211 | export function getModelSelectionOptionDescriptors( | |
| 212 | modelSelection: ModelSelection | null | undefined, | |
| 213 | caps?: ModelCapabilities | null | undefined, | |
| 214 | ): ReadonlyArray<ProviderOptionDescriptor> { | |
| 215 | if (!modelSelection) { | |
| 216 | return []; | |
| 170 | 217 | } |
| 218 | if (!caps) { | |
| 219 | return []; | |
| 220 | } | |
| 221 | return getProviderOptionDescriptors({ | |
| 222 | caps, | |
| 223 | selections: modelSelection.options, | |
| 224 | }); | |
| 171 | 225 | } |
| 172 | 226 | |
| 173 | 227 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| ... | ... | @@ -249,42 +303,51 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n |
| 249 | 303 | return trimmed || null; |
| 250 | 304 | } |
| 251 | 305 | |
| 306 | function cloneSelections( | |
| 307 | selections: ReadonlyArray<ProviderOptionSelection>, | |
| 308 | ): Array<ProviderOptionSelection> { | |
| 309 | return selections.map(cloneSelection); | |
| 310 | } | |
| 311 | ||
| 252 | 312 | export function createModelSelection( |
| 253 | 313 | provider: ProviderKind, |
| 254 | 314 | model: string, |
| 255 | options?: ProviderModelOptions[ProviderKind] | undefined, | |
| 315 | options?: ReadonlyArray<ProviderOptionSelection> | null, | |
| 256 | 316 | ): ModelSelection { |
| 257 | switch (provider) { | |
| 258 | case "codex": | |
| 259 | return { | |
| 260 | provider, | |
| 261 | model, | |
| 262 | ...(options ? { options: options as CodexModelOptions } : {}), | |
| 263 | }; | |
| 264 | case "claudeAgent": | |
| 265 | return { | |
| 266 | provider, | |
| 267 | model, | |
| 268 | ...(options ? { options: options as ClaudeModelOptions } : {}), | |
| 269 | }; | |
| 270 | case "cursor": | |
| 271 | return { | |
| 272 | provider, | |
| 273 | model, | |
| 274 | ...(options ? { options: options as CursorModelOptions } : {}), | |
| 275 | }; | |
| 276 | case "opencode": | |
| 277 | return { | |
| 278 | provider, | |
| 279 | model, | |
| 280 | ...(options ? { options: options as OpenCodeModelOptions } : {}), | |
| 281 | }; | |
| 317 | const selections = options ? cloneSelections(options) : []; | |
| 318 | return { | |
| 319 | provider, | |
| 320 | model, | |
| 321 | ...(selections.length > 0 ? { options: selections } : {}), | |
| 322 | } as ModelSelection; | |
| 323 | } | |
| 324 | ||
| 325 | /** | |
| 326 | * Returns the effort value if it is a prompt-injected value according to | |
| 327 | * any select descriptor in the given capabilities, or null otherwise. | |
| 328 | * | |
| 329 | * Unlike a single `find`, this checks every descriptor so that the | |
| 330 | * correct descriptor's `promptInjectedValues` list is consulted even when | |
| 331 | * multiple select descriptors exist. | |
| 332 | */ | |
| 333 | export function resolvePromptInjectedEffort( | |
| 334 | caps: ModelCapabilities, | |
| 335 | rawEffort: string | null | undefined, | |
| 336 | ): string | null { | |
| 337 | const trimmed = trimOrNull(rawEffort); | |
| 338 | if (!trimmed) return null; | |
| 339 | const descriptors = getProviderOptionDescriptors({ caps }); | |
| 340 | for (const descriptor of descriptors) { | |
| 341 | if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) { | |
| 342 | return trimmed; | |
| 343 | } | |
| 282 | 344 | } |
| 345 | return null; | |
| 283 | 346 | } |
| 284 | 347 | |
| 285 | 348 | export function applyClaudePromptEffortPrefix( |
| 286 | 349 | text: string, |
| 287 | effort: ClaudeAgentEffort | null | undefined, | |
| 350 | effort: string | null | undefined, | |
| 288 | 351 | ): string { |
| 289 | 352 | const trimmed = text.trim(); |
| 290 | 353 | if (!trimmed) { |
packages/shared/src/serverSettings.test.ts+40-31| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { createModelSelection } from "./model.ts"; | |
| 3 | 4 | import { |
| 4 | 5 | applyServerSettingsPatch, |
| 5 | 6 | extractPersistedServerObservabilitySettings, |
| ... | ... | @@ -56,14 +57,10 @@ describe("serverSettings helpers", () => { |
| 56 | 57 | it("replaces text generation selection when provider/model are provided", () => { |
| 57 | 58 | const current = { |
| 58 | 59 | ...DEFAULT_SERVER_SETTINGS, |
| 59 | textGenerationModelSelection: { | |
| 60 | provider: "codex" as const, | |
| 61 | model: "gpt-5.4-mini", | |
| 62 | options: { | |
| 63 | reasoningEffort: "high" as const, | |
| 64 | fastMode: true, | |
| 65 | }, | |
| 66 | }, | |
| 60 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | |
| 61 | { id: "reasoningEffort", value: "high" }, | |
| 62 | { id: "fastMode", value: true }, | |
| 63 | ]), | |
| 67 | 64 | }; |
| 68 | 65 | |
| 69 | 66 | expect( |
| ... | ... | @@ -82,45 +79,35 @@ describe("serverSettings helpers", () => { |
| 82 | 79 | it("still deep merges text generation selection when only options are provided", () => { |
| 83 | 80 | const current = { |
| 84 | 81 | ...DEFAULT_SERVER_SETTINGS, |
| 85 | textGenerationModelSelection: { | |
| 86 | provider: "codex" as const, | |
| 87 | model: "gpt-5.4-mini", | |
| 88 | options: { | |
| 89 | reasoningEffort: "high" as const, | |
| 90 | fastMode: true, | |
| 91 | }, | |
| 92 | }, | |
| 82 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | |
| 83 | { id: "reasoningEffort", value: "high" }, | |
| 84 | { id: "fastMode", value: true }, | |
| 85 | ]), | |
| 93 | 86 | }; |
| 94 | 87 | |
| 95 | 88 | expect( |
| 96 | 89 | applyServerSettingsPatch(current, { |
| 97 | 90 | textGenerationModelSelection: { |
| 98 | options: { | |
| 99 | fastMode: false, | |
| 100 | }, | |
| 91 | options: [{ id: "fastMode", value: false }], | |
| 101 | 92 | }, |
| 102 | 93 | }).textGenerationModelSelection, |
| 103 | 94 | ).toEqual({ |
| 104 | 95 | provider: "codex", |
| 105 | 96 | model: "gpt-5.4-mini", |
| 106 | options: { | |
| 107 | reasoningEffort: "high", | |
| 108 | fastMode: false, | |
| 109 | }, | |
| 97 | options: [ | |
| 98 | { id: "reasoningEffort", value: "high" }, | |
| 99 | { id: "fastMode", value: false }, | |
| 100 | ], | |
| 110 | 101 | }); |
| 111 | 102 | }); |
| 112 | 103 | |
| 113 | 104 | it("replaces text generation selection across providers without leaking stale options", () => { |
| 114 | 105 | const current = { |
| 115 | 106 | ...DEFAULT_SERVER_SETTINGS, |
| 116 | textGenerationModelSelection: { | |
| 117 | provider: "codex" as const, | |
| 118 | model: "gpt-5.4-mini", | |
| 119 | options: { | |
| 120 | reasoningEffort: "high" as const, | |
| 121 | fastMode: true, | |
| 122 | }, | |
| 123 | }, | |
| 107 | textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ | |
| 108 | { id: "reasoningEffort", value: "high" }, | |
| 109 | { id: "fastMode", value: true }, | |
| 110 | ]), | |
| 124 | 111 | }; |
| 125 | 112 | |
| 126 | 113 | expect( |
| ... | ... | @@ -135,4 +122,26 @@ describe("serverSettings helpers", () => { |
| 135 | 122 | model: "openai/gpt-5", |
| 136 | 123 | }); |
| 137 | 124 | }); |
| 125 | ||
| 126 | it("accepts array-based text generation selection patches", () => { | |
| 127 | expect( | |
| 128 | applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | |
| 129 | textGenerationModelSelection: { | |
| 130 | provider: "opencode", | |
| 131 | model: "openai/gpt-5", | |
| 132 | options: [ | |
| 133 | { id: "variant", value: "prod" }, | |
| 134 | { id: "agent", value: "build" }, | |
| 135 | ], | |
| 136 | }, | |
| 137 | }).textGenerationModelSelection, | |
| 138 | ).toEqual({ | |
| 139 | provider: "opencode", | |
| 140 | model: "openai/gpt-5", | |
| 141 | options: [ | |
| 142 | { id: "variant", value: "prod" }, | |
| 143 | { id: "agent", value: "build" }, | |
| 144 | ], | |
| 145 | }); | |
| 146 | }); | |
| 138 | 147 | }); |
packages/shared/src/serverSettings.ts+27-41| ... | ... | @@ -1,14 +1,8 @@ |
| 1 | import { | |
| 2 | ServerSettings, | |
| 3 | type ClaudeModelOptions, | |
| 4 | type CodexModelOptions, | |
| 5 | type CursorModelOptions, | |
| 6 | type OpenCodeModelOptions, | |
| 7 | type ServerSettingsPatch, | |
| 8 | } from "@t3tools/contracts"; | |
| 1 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | |
| 9 | 2 | import { Schema } from "effect"; |
| 10 | 3 | import { deepMerge } from "./Struct.ts"; |
| 11 | 4 | import { fromLenientJson } from "./schemaJson.ts"; |
| 5 | import { createModelSelection } from "./model.ts"; | |
| 12 | 6 | |
| 13 | 7 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 14 | 8 | |
| ... | ... | @@ -53,8 +47,23 @@ function shouldReplaceTextGenerationModelSelection( |
| 53 | 47 | return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); |
| 54 | 48 | } |
| 55 | 49 | |
| 56 | const withModelSelectionOptions = <Options>(options: Options | undefined) => | |
| 57 | options ? { options } : {}; | |
| 50 | function mergeModelSelectionOptionsById(input: { | |
| 51 | current: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; | |
| 52 | patch: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; | |
| 53 | }): Array<{ id: string; value: string | boolean }> | undefined { | |
| 54 | if (input.patch === undefined) { | |
| 55 | return input.current ? [...input.current] : undefined; | |
| 56 | } | |
| 57 | if (input.patch.length === 0) { | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | const merged = new Map((input.current ?? []).map((selection) => [selection.id, selection.value])); | |
| 62 | for (const selection of input.patch) { | |
| 63 | merged.set(selection.id, selection.value); | |
| 64 | } | |
| 65 | return [...merged.entries()].map(([id, value]) => ({ id, value })); | |
| 66 | } | |
| 58 | 67 | |
| 59 | 68 | /** |
| 60 | 69 | * Applies a server settings patch while treating textGenerationModelSelection as |
| ... | ... | @@ -67,44 +76,21 @@ export function applyServerSettingsPatch( |
| 67 | 76 | ): ServerSettings { |
| 68 | 77 | const selectionPatch = patch.textGenerationModelSelection; |
| 69 | 78 | const next = deepMerge(current, patch); |
| 70 | if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { | |
| 79 | if (!selectionPatch) { | |
| 71 | 80 | return next; |
| 72 | 81 | } |
| 73 | 82 | |
| 74 | 83 | const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; |
| 75 | 84 | const model = selectionPatch.model ?? current.textGenerationModelSelection.model; |
| 85 | const options = shouldReplaceTextGenerationModelSelection(selectionPatch) | |
| 86 | ? selectionPatch.options | |
| 87 | : mergeModelSelectionOptionsById({ | |
| 88 | current: current.textGenerationModelSelection.options, | |
| 89 | patch: selectionPatch.options, | |
| 90 | }); | |
| 76 | 91 | |
| 77 | 92 | return { |
| 78 | 93 | ...next, |
| 79 | textGenerationModelSelection: | |
| 80 | provider === "codex" | |
| 81 | ? { | |
| 82 | provider, | |
| 83 | model, | |
| 84 | ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined), | |
| 85 | } | |
| 86 | : provider === "claudeAgent" | |
| 87 | ? { | |
| 88 | provider, | |
| 89 | model, | |
| 90 | ...withModelSelectionOptions( | |
| 91 | selectionPatch.options as ClaudeModelOptions | undefined, | |
| 92 | ), | |
| 93 | } | |
| 94 | : provider === "cursor" | |
| 95 | ? { | |
| 96 | provider, | |
| 97 | model, | |
| 98 | ...withModelSelectionOptions( | |
| 99 | selectionPatch.options as CursorModelOptions | undefined, | |
| 100 | ), | |
| 101 | } | |
| 102 | : { | |
| 103 | provider, | |
| 104 | model, | |
| 105 | ...withModelSelectionOptions( | |
| 106 | selectionPatch.options as OpenCodeModelOptions | undefined, | |
| 107 | ), | |
| 108 | }, | |
| 94 | textGenerationModelSelection: createModelSelection(provider, model, options), | |
| 109 | 95 | }; |
| 110 | 96 | } |