authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-23 06:35:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-23 09:35:40-04:00
log8d1d699f88279066f085f95908c845334715830e
treee6932c1e2127927e48de16ad8815854e3ed33c23
parentb0b7b38da1dc4b19833d13f84eb907b1e2adfb63
signature Signed by PGP key B5690EEEBB952194

Refactor provider model selections to option arrays (#2246)

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 @@
1plans/
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+9-12
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it } from "@effect/vitest";
33import { Effect, FileSystem, Layer, Path } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";
45import { expect } from "vitest";
56
67import { ServerConfig } from "../../config.ts";
......@@ -199,12 +200,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
199200 stagedSummary: "M README.md",
200201 stagedPatch: "diff --git a/README.md b/README.md",
201202 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 ]),
208207 },
209208 });
210209
......@@ -235,12 +234,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
235234 diffSummary: "1 file changed",
236235 diffPatch: "diff --git a/README.md b/README.md",
237236 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 ]),
244241 },
245242 });
246243
apps/server/src/git/Layers/ClaudeTextGeneration.ts+28-12
......@@ -28,10 +28,17 @@ import {
2828 sanitizeThreadTitle,
2929 toJsonSchemaObject,
3030} from "../Utils.ts";
31import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
31import {
32 getModelSelectionStringOptionValue,
33 getProviderOptionDescriptors,
34} from "@t3tools/shared/model";
35import {
36 getClaudeModelCapabilities,
37 normalizeClaudeCliEffort,
38 resolveClaudeApiModelId,
39 resolveClaudeEffort,
40} from "../../provider/Layers/ClaudeProvider.ts";
3341import { ServerSettingsService } from "../../serverSettings.ts";
34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3542
3643const CLAUDE_TIMEOUT_MS = 180_000;
3744
......@@ -84,15 +91,24 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8491 modelSelection: ClaudeModelSelection;
8592 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
8693 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;
91109 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 } : {}),
96112 };
97113
98114 const claudeSettings = yield* Effect.map(
......@@ -111,7 +127,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
111127 jsonSchemaStr,
112128 "--model",
113129 resolveClaudeApiModelId(modelSelection),
114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
130 ...(cliEffort ? ["--effort", cliEffort] : []),
115131 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116132 "--dangerously-skip-permissions",
117133 ],
apps/server/src/git/Layers/CodexTextGeneration.test.ts+5-8
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it } from "@effect/vitest";
33import { Effect, FileSystem, Layer, Path, Result } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";
45import { expect } from "vitest";
56
67import { ServerConfig } from "../../config.ts";
......@@ -244,14 +245,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
244245 branch: "feature/codex-effect",
245246 stagedSummary: "M README.md",
246247 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 ]),
255252 });
256253 }),
257254 ),
apps/server/src/git/Layers/CodexTextGeneration.ts+9-2
......@@ -30,6 +30,10 @@ import {
3030 toJsonSchemaObject,
3131} from "../Utils.ts";
3232import { ServerSettingsService } from "../../serverSettings.ts";
33import {
34 getModelSelectionBooleanOptionValue,
35 getModelSelectionStringOptionValue,
36} from "@t3tools/shared/model";
3337
3438const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
3539const CODEX_TIMEOUT_MS = 180_000;
......@@ -155,7 +159,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
155159
156160 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
157161 const reasoningEffort =
158 modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
162 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
163 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
159164 const command = ChildProcess.make(
160165 codexSettings?.binaryPath || "codex",
161166 [
......@@ -168,7 +173,9 @@ const makeCodexTextGeneration = Effect.gen(function* () {
168173 modelSelection.model,
169174 "--config",
170175 `model_reasoning_effort="${reasoningEffort}"`,
171 ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []),
176 ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true
177 ? ["--config", `service_tier="fast"`]
178 : []),
172179 "--output-schema",
173180 schemaPath,
174181 "--output-last-message",
apps/server/src/git/Layers/CursorTextGeneration.test.ts+6-7
......@@ -6,6 +6,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { it } from "@effect/vitest";
88import { Effect, Layer } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";
910import { expect } from "vitest";
1011
1112import { ServerSettingsError } from "@t3tools/contracts";
......@@ -135,13 +136,11 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
135136 stagedPatch:
136137 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137138 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 ]),
145144 },
146145 });
147146
apps/server/src/git/Layers/CursorTextGeneration.ts+1-1
......@@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
108108 yield* applyCursorAcpModelSelection({
109109 runtime,
110110 model: modelSelection.model,
111 modelOptions: modelSelection.options,
111 selections: modelSelection.options,
112112 mapError: ({ cause, configId, step }) =>
113113 mapCursorAcpError(
114114 operation,
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+8-6
......@@ -7,6 +7,7 @@ import {
77 type OpenCodeModelSelection,
88} from "@t3tools/contracts";
99import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1011
1112import { ServerConfig } from "../../config.ts";
1213import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -320,16 +321,17 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
320321 if (!session.data) {
321322 throw new Error("OpenCode session.create returned no session payload.");
322323 }
324 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
325 const selectedVariant = getModelSelectionStringOptionValue(
326 input.modelSelection,
327 "variant",
328 );
323329
324330 const result = await client.session.prompt({
325331 sessionID: session.data.id,
326332 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 } : {}),
333335 parts: [{ type: "text", text: input.prompt }, ...fileParts],
334336 });
335337 const info = result.data?.info;
apps/server/src/git/Layers/RoutingTextGeneration.ts+14-22
......@@ -11,11 +11,7 @@
1111 */
1212import { Effect, Layer, Context } from "effect";
1313
14import {
15 TextGeneration,
16 type TextGenerationProvider,
17 type TextGenerationShape,
18} from "../Services/TextGeneration.ts";
14import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts";
1915import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2016import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
2117import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
......@@ -46,26 +42,22 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha
4642// ---------------------------------------------------------------------------
4743
4844const 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 };
6251
6352 return {
6453 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),
6961 } satisfies TextGenerationShape;
7062});
7163
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+40-87
......@@ -3,6 +3,7 @@ import os from "node:os";
33import path from "node:path";
44
55import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";
67import {
78 ApprovalRequestId,
89 CommandId,
......@@ -567,14 +568,10 @@ describe("ProviderCommandReactor", () => {
567568 text: "hello fast mode",
568569 attachments: [],
569570 },
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 ]),
578575 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
579576 runtimeMode: "approval-required",
580577 createdAt: now,
......@@ -584,25 +581,17 @@ describe("ProviderCommandReactor", () => {
584581 await waitFor(() => harness.startSession.mock.calls.length === 1);
585582 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
586583 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 ]),
595588 });
596589 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
597590 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 ]),
606595 });
607596 });
608597
......@@ -623,13 +612,9 @@ describe("ProviderCommandReactor", () => {
623612 text: "hello with effort",
624613 attachments: [],
625614 },
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 ]),
633618 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
634619 runtimeMode: "approval-required",
635620 createdAt: now,
......@@ -639,23 +624,15 @@ describe("ProviderCommandReactor", () => {
639624 await waitFor(() => harness.startSession.mock.calls.length === 1);
640625 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
641626 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 ]),
649630 });
650631 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
651632 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 ]),
659636 });
660637 });
661638
......@@ -676,13 +653,9 @@ describe("ProviderCommandReactor", () => {
676653 text: "hello with fast mode",
677654 attachments: [],
678655 },
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 ]),
686659 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
687660 runtimeMode: "approval-required",
688661 createdAt: now,
......@@ -692,23 +665,15 @@ describe("ProviderCommandReactor", () => {
692665 await waitFor(() => harness.startSession.mock.calls.length === 1);
693666 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
694667 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 ]),
702671 });
703672 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
704673 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 ]),
712677 });
713678 });
714679
......@@ -916,13 +881,9 @@ describe("ProviderCommandReactor", () => {
916881 text: "first claude turn",
917882 attachments: [],
918883 },
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 ]),
926887 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
927888 runtimeMode: "approval-required",
928889 createdAt: now,
......@@ -943,13 +904,9 @@ describe("ProviderCommandReactor", () => {
943904 text: "second claude turn",
944905 attachments: [],
945906 },
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 ]),
953910 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
954911 runtimeMode: "approval-required",
955912 createdAt: now,
......@@ -960,13 +917,9 @@ describe("ProviderCommandReactor", () => {
960917 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
961918 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
962919 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 ]),
970923 });
971924 });
972925
apps/server/src/orchestration/decider.projectScripts.test.ts+9-16
......@@ -6,6 +6,7 @@ import {
66 ProjectId,
77 ThreadId,
88} from "@t3tools/contracts";
9import { createModelSelection } from "@t3tools/shared/model";
910import { describe, expect, it } from "vitest";
1011import { Effect } from "effect";
1112
......@@ -162,14 +163,10 @@ describe("decider project scripts", () => {
162163 text: "hello",
163164 attachments: [],
164165 },
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 ]),
173170 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
174171 runtimeMode: "approval-required",
175172 createdAt: now,
......@@ -191,14 +188,10 @@ describe("decider project scripts", () => {
191188 expect(turnStartEvent.payload).toMatchObject({
192189 threadId: ThreadId.make("thread-1"),
193190 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 ]),
202195 runtimeMode: "approval-required",
203196 });
204197 });
apps/server/src/persistence/Migrations.ts+2
......@@ -38,6 +38,7 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
3838import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
3939import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
4040import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";
41import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
4142
4243/**
4344 * Migration loader with all migrations defined inline.
......@@ -75,6 +76,7 @@ export const migrationEntries = [
7576 [23, "ProjectionThreadShellSummary", Migration0023],
7677 [24, "BackfillProjectionThreadShellSummary", Migration0024],
7778 [25, "CleanupInvalidProjectionPendingApprovals", Migration0025],
79 [26, "CanonicalizeModelSelectionOptions", Migration0026],
7880] as const;
7981
8082export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts created+450
......@@ -0,0 +1,450 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("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 @@
1import * as Effect from "effect/Effect";
2import * 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 */
28export 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 {
1717 type RuntimeMode,
1818 ThreadId,
1919} from "@t3tools/contracts";
20import { createModelSelection } from "@t3tools/shared/model";
2021import { assert, describe, it } from "@effect/vitest";
2122import { Effect, Fiber, Layer, Random, Stream } from "effect";
2223
......@@ -333,13 +334,9 @@ describe("ClaudeAdapterLive", () => {
333334 yield* adapter.startSession({
334335 threadId: THREAD_ID,
335336 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 ]),
343340 runtimeMode: "full-access",
344341 });
345342
......@@ -380,13 +377,9 @@ describe("ClaudeAdapterLive", () => {
380377 yield* adapter.startSession({
381378 threadId: THREAD_ID,
382379 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 ]),
390383 runtimeMode: "full-access",
391384 });
392385
......@@ -405,13 +398,9 @@ describe("ClaudeAdapterLive", () => {
405398 yield* adapter.startSession({
406399 threadId: THREAD_ID,
407400 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 ]),
415404 runtimeMode: "full-access",
416405 });
417406
......@@ -430,13 +419,9 @@ describe("ClaudeAdapterLive", () => {
430419 yield* adapter.startSession({
431420 threadId: THREAD_ID,
432421 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 ]),
440425 runtimeMode: "full-access",
441426 });
442427
......@@ -455,13 +440,9 @@ describe("ClaudeAdapterLive", () => {
455440 yield* adapter.startSession({
456441 threadId: THREAD_ID,
457442 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 ]),
465446 runtimeMode: "full-access",
466447 });
467448
......@@ -482,13 +463,9 @@ describe("ClaudeAdapterLive", () => {
482463 yield* adapter.startSession({
483464 threadId: THREAD_ID,
484465 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 ]),
492469 runtimeMode: "full-access",
493470 });
494471
......@@ -507,13 +484,9 @@ describe("ClaudeAdapterLive", () => {
507484 yield* adapter.startSession({
508485 threadId: THREAD_ID,
509486 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 ]),
517490 runtimeMode: "full-access",
518491 });
519492
......@@ -534,13 +507,9 @@ describe("ClaudeAdapterLive", () => {
534507 yield* adapter.startSession({
535508 threadId: THREAD_ID,
536509 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 ]),
544513 runtimeMode: "full-access",
545514 });
546515
......@@ -559,13 +528,9 @@ describe("ClaudeAdapterLive", () => {
559528 const session = yield* adapter.startSession({
560529 threadId: THREAD_ID,
561530 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 ]),
569534 runtimeMode: "full-access",
570535 });
571536
......@@ -573,13 +538,9 @@ describe("ClaudeAdapterLive", () => {
573538 threadId: session.threadId,
574539 input: "Investigate the edge cases",
575540 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 ]),
583544 });
584545
585546 const createInput = harness.getLastCreateQueryInput();
......@@ -2794,13 +2755,9 @@ describe("ClaudeAdapterLive", () => {
27942755 yield* adapter.sendTurn({
27952756 threadId: session.threadId,
27962757 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 ]),
28042761 attachments: [],
28052762 });
28062763 yield* adapter.sendTurn({
apps/server/src/provider/Layers/ClaudeAdapter.ts+35-29
......@@ -40,9 +40,14 @@ import {
4040 ThreadId,
4141 TurnId,
4242 type UserInputQuestion,
43 ClaudeAgentEffort,
4443} from "@t3tools/contracts";
45import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model";
44import {
45 applyClaudePromptEffortPrefix,
46 getModelSelectionBooleanOptionValue,
47 getModelSelectionStringOptionValue,
48 getProviderOptionDescriptors,
49 resolvePromptInjectedEffort,
50} from "@t3tools/shared/model";
4651import {
4752 Cause,
4853 DateTime,
......@@ -61,7 +66,12 @@ import {
6166import { resolveAttachmentPath } from "../../attachmentStore.ts";
6267import { ServerConfig } from "../../config.ts";
6368import { ServerSettingsService } from "../../serverSettings.ts";
64import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts";
69import {
70 getClaudeModelCapabilities,
71 normalizeClaudeCliEffort,
72 resolveClaudeApiModelId,
73 resolveClaudeEffort,
74} from "./ClaudeProvider.ts";
6575import {
6676 ProviderAdapterProcessError,
6777 ProviderAdapterRequestError,
......@@ -211,19 +221,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
211221 return squashed.length > 0 ? [squashed] : [];
212222}
213223
214function 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;
224function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null {
225 const normalized = normalizeClaudeCliEffort(effort);
226 return normalized ? (normalized as ClaudeSdkEffort) : null;
227227}
228228
229229function isClaudeInterruptedMessage(message: string): boolean {
......@@ -555,16 +555,14 @@ const CLAUDE_SETTING_SOURCES = [
555555
556556function buildPromptText(input: ProviderSendTurnInput): string {
557557 const rawEffort =
558 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
558 input.modelSelection?.provider === "claudeAgent"
559 ? getModelSelectionStringOptionValue(input.modelSelection, "effort")
560 : null;
559561 const claudeModel =
560562 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
561563 const caps = getClaudeModelCapabilities(claudeModel);
562564
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);
568566 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
569567}
570568
......@@ -2824,14 +2822,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28242822 const modelSelection =
28252823 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
28262824 const caps = getClaudeModelCapabilities(modelSelection?.model);
2825 const descriptors = getProviderOptionDescriptors({ caps });
28272826 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;
28352841 const effectiveEffort = getEffectiveClaudeAgentEffort(effort);
28362842 const runtimeModeToPermission: Record<string, PermissionMode> = {
28372843 "auto-accept-edits": "acceptEdits",
apps/server/src/provider/Layers/ClaudeProvider.ts+156-71
......@@ -11,6 +11,12 @@ import type {
1111import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
1212import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1313import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import {
15 createModelCapabilities,
16 getModelSelectionStringOptionValue,
17 getProviderOptionCurrentValue,
18 getProviderOptionDescriptors,
19} from "@t3tools/shared/model";
1420import {
1521 query as claudeQuery,
1622 type SlashCommand as ClaudeSlashCommand,
......@@ -18,6 +24,8 @@ import {
1824} from "@anthropic-ai/claude-agent-sdk";
1925
2026import {
27 buildBooleanOptionDescriptor,
28 buildSelectOptionDescriptor,
2129 buildServerProvider,
2230 AUTH_PROBE_TIMEOUT_MS,
2331 DEFAULT_TIMEOUT_MS,
......@@ -35,108 +43,143 @@ import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
3543import { ServerSettingsService } from "../../serverSettings.ts";
3644import { ServerSettingsError } from "@t3tools/contracts";
3745
38const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = {
39 reasoningEffortLevels: [],
40 supportsFastMode: false,
41 supportsThinkingToggle: false,
42 contextWindowOptions: [],
43 promptInjectedEffortLevels: [],
44};
46const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
47 optionDescriptors: [],
48});
4549
4650const PROVIDER = "claudeAgent" as const;
51const CLAUDE_PRESENTATION = {
52 displayName: "Claude",
53 showInteractionModeToggle: true,
54} as const;
4755const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
4856const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
4957 {
5058 slug: "claude-opus-4-7",
5159 name: "Claude Opus 4.7",
5260 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 }),
6184 ],
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 }),
7086 },
7187 {
7288 slug: "claude-opus-4-6",
7389 name: "Claude Opus 4.6",
7490 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 }),
88117 ],
89 promptInjectedEffortLevels: ["ultrathink"],
90 } satisfies ModelCapabilities,
118 }),
91119 },
92120 {
93121 slug: "claude-opus-4-5",
94122 name: "Claude Opus 4.5",
95123 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 }),
102140 ],
103 supportsFastMode: true,
104 supportsThinkingToggle: false,
105 contextWindowOptions: [],
106 promptInjectedEffortLevels: [],
107 } satisfies ModelCapabilities,
141 }),
108142 },
109143 {
110144 slug: "claude-sonnet-4-6",
111145 name: "Claude Sonnet 4.6",
112146 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 }),
125168 ],
126 promptInjectedEffortLevels: ["ultrathink"],
127 } satisfies ModelCapabilities,
169 }),
128170 },
129171 {
130172 slug: "claude-haiku-4-5",
131173 name: "Claude Haiku 4.5",
132174 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 }),
140183 },
141184];
142185
......@@ -166,8 +209,41 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
166209 );
167210}
168211
212export 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 */
235export 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
169245export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {
170 switch (modelSelection.options?.contextWindow) {
246 switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) {
171247 case "1m":
172248 return `${modelSelection.model}[1m]`;
173249 default:
......@@ -579,6 +655,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
579655 if (!claudeSettings.enabled) {
580656 return buildServerProvider({
581657 provider: PROVIDER,
658 presentation: CLAUDE_PRESENTATION,
582659 enabled: false,
583660 checkedAt,
584661 models: allModels,
......@@ -601,6 +678,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
601678 const error = versionProbe.failure;
602679 return buildServerProvider({
603680 provider: PROVIDER,
681 presentation: CLAUDE_PRESENTATION,
604682 enabled: claudeSettings.enabled,
605683 checkedAt,
606684 models: allModels,
......@@ -619,6 +697,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
619697 if (Option.isNone(versionProbe.success)) {
620698 return buildServerProvider({
621699 provider: PROVIDER,
700 presentation: CLAUDE_PRESENTATION,
622701 enabled: claudeSettings.enabled,
623702 checkedAt,
624703 models: allModels,
......@@ -639,6 +718,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
639718 const detail = detailFromResult(version);
640719 return buildServerProvider({
641720 provider: PROVIDER,
721 presentation: CLAUDE_PRESENTATION,
642722 enabled: claudeSettings.enabled,
643723 checkedAt,
644724 models: allModels,
......@@ -703,6 +783,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
703783 const error = authProbe.failure;
704784 return buildServerProvider({
705785 provider: PROVIDER,
786 presentation: CLAUDE_PRESENTATION,
706787 enabled: claudeSettings.enabled,
707788 checkedAt,
708789 models,
......@@ -723,6 +804,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
723804 if (Option.isNone(authProbe.success)) {
724805 return buildServerProvider({
725806 provider: PROVIDER,
807 presentation: CLAUDE_PRESENTATION,
726808 enabled: claudeSettings.enabled,
727809 checkedAt,
728810 models,
......@@ -741,6 +823,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
741823 const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod });
742824 return buildServerProvider({
743825 provider: PROVIDER,
826 presentation: CLAUDE_PRESENTATION,
744827 enabled: claudeSettings.enabled,
745828 checkedAt,
746829 models,
......@@ -774,6 +857,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid
774857 if (!claudeSettings.enabled) {
775858 return buildServerProvider({
776859 provider: PROVIDER,
860 presentation: CLAUDE_PRESENTATION,
777861 enabled: false,
778862 checkedAt,
779863 models,
......@@ -789,6 +873,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid
789873
790874 return buildServerProvider({
791875 provider: PROVIDER,
876 presentation: CLAUDE_PRESENTATION,
792877 enabled: true,
793878 checkedAt,
794879 models,
apps/server/src/provider/Layers/CodexAdapter.test.ts+8-15
......@@ -14,6 +14,7 @@ import {
1414 ThreadId,
1515 TurnId,
1616} from "@t3tools/contracts";
17import { createModelSelection } from "@t3tools/shared/model";
1718import * as NodeServices from "@effect/platform-node/NodeServices";
1819import { it, vi } from "@effect/vitest";
1920
......@@ -242,13 +243,9 @@ validationLayer("CodexAdapterLive validation", (it) => {
242243 yield* adapter.startSession({
243244 provider: "codex",
244245 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 ]),
252249 runtimeMode: "full-access",
253250 });
254251
......@@ -309,14 +306,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
309306 adapter.sendTurn({
310307 threadId: asThreadId("sess-missing"),
311308 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 ]),
320313 attachments: [],
321314 }),
322315 );
apps/server/src/provider/Layers/CodexAdapter.ts+20-7
......@@ -26,6 +26,11 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2626import * as CodexErrors from "effect-codex-app-server/errors";
2727import * as EffectCodexSchema from "effect-codex-app-server/schema";
2828
29import {
30 getModelSelectionBooleanOptionValue,
31 getModelSelectionStringOptionValue,
32} from "@t3tools/shared/model";
33
2934import {
3035 ProviderAdapterRequestError,
3136 ProviderAdapterProcessError,
......@@ -1372,7 +1377,8 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
13721377 ...(input.modelSelection?.provider === "codex"
13731378 ? { model: input.modelSelection.model }
13741379 : {}),
1375 ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode
1380 ...(input.modelSelection?.provider === "codex" &&
1381 getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true
13761382 ? { serviceTier: "fast" }
13771383 : {}),
13781384 };
......@@ -1485,19 +1491,26 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
14851491 );
14861492
14871493 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;
14881502 return yield* session.runtime
14891503 .sendTurn({
14901504 ...(input.input !== undefined ? { input: input.input } : {}),
14911505 ...(input.modelSelection?.provider === "codex"
14921506 ? { model: input.modelSelection.model }
14931507 : {}),
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 }
15001512 : {}),
1513 ...(fastMode === true ? { serviceTier: "fast" } : {}),
15011514 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
15021515 ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}),
15031516 })
apps/server/src/provider/Layers/CodexProvider.ts+50-11
......@@ -25,6 +25,8 @@ import type {
2525} from "@t3tools/contracts";
2626import { ServerSettingsError } from "@t3tools/contracts";
2727
28import { createModelCapabilities } from "@t3tools/shared/model";
29
2830import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
2931import { buildServerProvider } from "../providerSnapshot.ts";
3032import { CodexProvider } from "../Services/CodexProvider.ts";
......@@ -34,6 +36,10 @@ import packageJson from "../../../package.json" with { type: "json" };
3436
3537const PROVIDER = "codex" as const;
3638const PROVIDER_PROBE_TIMEOUT_MS = 8_000;
39const CODEX_PRESENTATION = {
40 displayName: "Codex",
41 showInteractionModeToggle: true,
42} as const;
3743
3844export interface CodexAppServerProviderSnapshot {
3945 readonly account: CodexSchema.V2GetAccountResponse;
......@@ -87,17 +93,44 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
8793function mapCodexModelCapabilities(
8894 model: CodexSchema.V2ModelListResponse__Model,
8995): 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 });
101134}
102135
103136const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => {
......@@ -292,6 +325,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider
292325 if (!codexSettings.enabled) {
293326 return buildServerProvider({
294327 provider: PROVIDER,
328 presentation: CODEX_PRESENTATION,
295329 enabled: false,
296330 checkedAt,
297331 models,
......@@ -308,6 +342,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider
308342
309343 return buildServerProvider({
310344 provider: PROVIDER,
345 presentation: CODEX_PRESENTATION,
311346 enabled: true,
312347 checkedAt,
313348 models,
......@@ -375,6 +410,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
375410 if (!codexSettings.enabled) {
376411 return buildServerProvider({
377412 provider: PROVIDER,
413 presentation: CODEX_PRESENTATION,
378414 enabled: false,
379415 checkedAt,
380416 models: emptyModels,
......@@ -401,6 +437,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
401437 const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error);
402438 return buildServerProvider({
403439 provider: PROVIDER,
440 presentation: CODEX_PRESENTATION,
404441 enabled: codexSettings.enabled,
405442 checkedAt,
406443 models: emptyModels,
......@@ -420,6 +457,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
420457 if (Option.isNone(probeResult.success)) {
421458 return buildServerProvider({
422459 provider: PROVIDER,
460 presentation: CODEX_PRESENTATION,
423461 enabled: codexSettings.enabled,
424462 checkedAt,
425463 models: emptyModels,
......@@ -439,6 +477,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
439477
440478 return buildServerProvider({
441479 provider: PROVIDER,
480 presentation: CODEX_PRESENTATION,
442481 enabled: codexSettings.enabled,
443482 checkedAt,
444483 models: snapshot.models,
apps/server/src/provider/Layers/CursorAdapter.test.ts+15-12
......@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { assert, it } from "@effect/vitest";
88import { Deferred, Effect, Fiber, Layer, Stream } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";
910
1011import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts";
1112
......@@ -357,15 +358,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
357358 providers: { cursor: { binaryPath: wrapperPath } },
358359 });
359360
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 ]);
369366
370367 yield* adapter.startSession({
371368 threadId,
......@@ -1092,7 +1089,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10921089 threadId,
10931090 input: "second turn after switching model",
10941091 attachments: [],
1095 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1092 modelSelection: createModelSelection("cursor", "composer-2", [
1093 { id: "fastMode", value: true },
1094 ]),
10961095 });
10971096
10981097 const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath));
......@@ -1147,14 +1146,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
11471146 threadId,
11481147 input: "first turn with fast mode",
11491148 attachments: [],
1150 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1149 modelSelection: createModelSelection("cursor", "composer-2", [
1150 { id: "fastMode", value: true },
1151 ]),
11511152 });
11521153
11531154 yield* adapter.sendTurn({
11541155 threadId,
11551156 input: "second turn without fast mode",
11561157 attachments: [],
1157 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } },
1158 modelSelection: createModelSelection("cursor", "composer-2", [
1159 { id: "fastMode", value: false },
1160 ]),
11581161 });
11591162
11601163 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";
77
88import {
99 ApprovalRequestId,
10 type CursorModelOptions,
10 type ProviderOptionSelection,
1111 EventId,
1212 type ProviderApprovalDecision,
1313 type ProviderInteractionMode,
......@@ -222,7 +222,7 @@ function applyRequestedSessionConfiguration<E>(input: {
222222 readonly modelSelection:
223223 | {
224224 readonly model: string;
225 readonly options?: CursorModelOptions | null | undefined;
225 readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined;
226226 }
227227 | undefined;
228228 readonly mapError: (context: {
......@@ -235,7 +235,7 @@ function applyRequestedSessionConfiguration<E>(input: {
235235 yield* applyCursorAcpModelSelection({
236236 runtime: input.runtime,
237237 model: input.modelSelection.model,
238 modelOptions: input.modelSelection.options,
238 selections: input.modelSelection.options,
239239 mapError: ({ cause }) =>
240240 input.mapError({
241241 cause,
apps/server/src/provider/Layers/CursorProvider.test.ts+93-102
......@@ -8,6 +8,7 @@ import { Effect } from "effect";
88import { describe, expect, it } from "vitest";
99import type * as EffectAcpSchema from "effect-acp/schema";
1010import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";
11import { createModelCapabilities } from "@t3tools/shared/model";
1112
1213import {
1314 buildCursorProviderSnapshot,
......@@ -27,6 +28,31 @@ import {
2728const __dirname = path.dirname(fileURLToPath(import.meta.url));
2829const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
2930
31function 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
47function 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
3056async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {
3157 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-"));
3258 const wrapperPath = path.join(dir, "fake-agent.sh");
......@@ -239,13 +265,7 @@ const baseCursorSettings: CursorSettings = {
239265 customModels: [],
240266};
241267
242const emptyCapabilities = {
243 reasoningEffortLevels: [],
244 supportsFastMode: false,
245 supportsThinkingToggle: false,
246 contextWindowOptions: [],
247 promptInjectedEffortLevels: [],
248} as const;
268const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
249269
250270describe("getCursorFallbackModels", () => {
251271 it("does not publish any built-in cursor models before ACP discovery", () => {
......@@ -309,52 +329,57 @@ describe("buildCursorProviderSnapshot", () => {
309329
310330describe("buildCursorCapabilitiesFromConfigOptions", () => {
311331 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 );
327349 });
328350
329351 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 );
341364 });
342365
343366 it("prefers the newer model_option effort control over legacy thought_level", () => {
344367 expect(
345368 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 );
358383 });
359384});
360385
......@@ -365,73 +390,39 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
365390 slug: "default",
366391 name: "Auto",
367392 isCustom: false,
368 capabilities: {
369 reasoningEffortLevels: [],
370 supportsFastMode: false,
371 supportsThinkingToggle: false,
372 contextWindowOptions: [],
373 promptInjectedEffortLevels: [],
374 },
393 capabilities: emptyCapabilities,
375394 },
376395 {
377396 slug: "composer-2",
378397 name: "Composer 2",
379398 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 }),
387402 },
388403 {
389404 slug: "gpt-5.4",
390405 name: "GPT-5.4",
391406 isCustom: false,
392 capabilities: {
393 reasoningEffortLevels: [],
394 supportsFastMode: false,
395 supportsThinkingToggle: false,
396 contextWindowOptions: [],
397 promptInjectedEffortLevels: [],
398 },
407 capabilities: emptyCapabilities,
399408 },
400409 {
401410 slug: "claude-sonnet-4-6",
402411 name: "Sonnet 4.6",
403412 isCustom: false,
404 capabilities: {
405 reasoningEffortLevels: [],
406 supportsFastMode: false,
407 supportsThinkingToggle: false,
408 contextWindowOptions: [],
409 promptInjectedEffortLevels: [],
410 },
413 capabilities: emptyCapabilities,
411414 },
412415 {
413416 slug: "claude-opus-4-6",
414417 name: "Opus 4.6",
415418 isCustom: false,
416 capabilities: {
417 reasoningEffortLevels: [],
418 supportsFastMode: false,
419 supportsThinkingToggle: false,
420 contextWindowOptions: [],
421 promptInjectedEffortLevels: [],
422 },
419 capabilities: emptyCapabilities,
423420 },
424421 {
425422 slug: "gpt-5.3-codex-spark",
426423 name: "Codex 5.3 Spark",
427424 isCustom: false,
428 capabilities: {
429 reasoningEffortLevels: [],
430 supportsFastMode: false,
431 supportsThinkingToggle: false,
432 contextWindowOptions: [],
433 promptInjectedEffortLevels: [],
434 },
425 capabilities: emptyCapabilities,
435426 },
436427 ]);
437428 });
......@@ -645,11 +636,11 @@ describe("resolveCursorAcpBaseModelId", () => {
645636describe("resolveCursorAcpConfigUpdates", () => {
646637 it("maps Cursor model options onto separate ACP config option updates", () => {
647638 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 ]),
653644 ).toEqual([
654645 { configId: "reasoning", value: "extra-high" },
655646 { configId: "context", value: "1m" },
......@@ -659,26 +650,26 @@ describe("resolveCursorAcpConfigUpdates", () => {
659650
660651 it("maps boolean thinking toggles when the model exposes them separately", () => {
661652 expect(
662 resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, {
663 thinking: false,
664 }),
653 resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [
654 { id: "thinking", value: false },
655 ]),
665656 ).toEqual([{ configId: "thinking", value: false }]);
666657 });
667658
668659 it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => {
669660 expect(
670 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, {
671 fastMode: false,
672 }),
661 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [
662 { id: "fastMode", value: false },
663 ]),
673664 ).toEqual([{ configId: "fast", value: "false" }]);
674665 });
675666
676667 it("writes Cursor effort changes through the newer model_option config when available", () => {
677668 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 ]),
682673 ).toEqual([
683674 { configId: "effort", value: "max" },
684675 { configId: "thinking", value: "false" },
apps/server/src/provider/Layers/CursorProvider.ts+113-31
......@@ -3,9 +3,9 @@ import * as nodeOs from "node:os";
33import * as nodePath from "node:path";
44
55import type {
6 CursorModelOptions,
76 CursorSettings,
87 ModelCapabilities,
8 ProviderOptionSelection,
99 ServerProvider,
1010 ServerProviderAuth,
1111 ServerProviderModel,
......@@ -15,8 +15,15 @@ import type {
1515import type * as EffectAcpSchema from "effect-acp/schema";
1616import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect";
1717import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18import {
19 createModelCapabilities,
20 getProviderOptionBooleanSelectionValue,
21 getProviderOptionStringSelectionValue,
22} from "@t3tools/shared/model";
1823
1924import {
25 buildBooleanOptionDescriptor,
26 buildSelectOptionDescriptor,
2027 buildServerProvider,
2128 collectStreamAsString,
2229 isCommandMissingCause,
......@@ -29,13 +36,14 @@ import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
2936import { ServerSettingsService } from "../../serverSettings.ts";
3037
3138const PROVIDER = "cursor" as const;
32const EMPTY_CAPABILITIES: ModelCapabilities = {
33 reasoningEffortLevels: [],
34 supportsFastMode: false,
35 supportsThinkingToggle: false,
36 contextWindowOptions: [],
37 promptInjectedEffortLevels: [],
38};
39const CURSOR_PRESENTATION = {
40 displayName: "Cursor",
41 badgeLabel: "Early Access",
42 showInteractionModeToggle: true,
43} as const;
44const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
45 optionDescriptors: [],
46});
3947
4048const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
4149const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
......@@ -55,6 +63,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser
5563 if (!cursorSettings.enabled) {
5664 return buildServerProvider({
5765 provider: PROVIDER,
66 presentation: CURSOR_PRESENTATION,
5867 enabled: false,
5968 checkedAt,
6069 models,
......@@ -70,6 +79,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser
7079
7180 return buildServerProvider({
7281 provider: PROVIDER,
82 presentation: CURSOR_PRESENTATION,
7383 enabled: true,
7484 checkedAt,
7585 models,
......@@ -198,6 +208,28 @@ function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption):
198208 return values.has("true") && values.has("false");
199209}
200210
211function 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
201233export function buildCursorCapabilitiesFromConfigOptions(
202234 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
203235): ModelCapabilities {
......@@ -251,14 +283,60 @@ export function buildCursorCapabilitiesFromConfigOptions(
251283 const thinkingOption = configOptions.find(
252284 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
253285 );
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 ];
254336
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 });
262340}
263341
264342function buildCursorDiscoveredModels(
......@@ -282,13 +360,7 @@ function buildCursorDiscoveredModels(
282360}
283361
284362function 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;
292364}
293365
294366export function buildCursorDiscoveredModelsFromConfigOptions(
......@@ -387,7 +459,7 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s
387459
388460export function resolveCursorAcpConfigUpdates(
389461 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
390 modelOptions: CursorModelOptions | null | undefined,
462 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
391463): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> {
392464 if (!configOptions || configOptions.length === 0) {
393465 return [];
......@@ -396,7 +468,9 @@ export function resolveCursorAcpConfigUpdates(
396468 const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = [];
397469
398470 const reasoningOption = findCursorEffortConfigOption(configOptions);
399 const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning);
471 const requestedReasoning = normalizeCursorReasoningValue(
472 getProviderOptionStringSelectionValue(selections, "reasoning"),
473 );
400474 if (reasoningOption && requestedReasoning) {
401475 const value = findCursorSelectOptionValue(reasoningOption, (option) => {
402476 const normalizedValue = normalizeCursorReasoningValue(option.value);
......@@ -411,14 +485,15 @@ export function resolveCursorAcpConfigUpdates(
411485 const contextOption = configOptions.find(
412486 (option) => option.category === "model_config" && isCursorContextConfigOption(option),
413487 );
414 if (contextOption && modelOptions?.contextWindow) {
488 const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow");
489 if (contextOption && requestedContextWindow) {
415490 const value = findCursorSelectOptionValue(
416491 contextOption,
417492 (option) =>
418493 normalizeCursorConfigOptionToken(option.value) ===
419 normalizeCursorConfigOptionToken(modelOptions.contextWindow) ||
494 normalizeCursorConfigOptionToken(requestedContextWindow) ||
420495 normalizeCursorConfigOptionToken(option.name) ===
421 normalizeCursorConfigOptionToken(modelOptions.contextWindow),
496 normalizeCursorConfigOptionToken(requestedContextWindow),
422497 );
423498 if (value) {
424499 updates.push({ configId: contextOption.id, value });
......@@ -428,8 +503,9 @@ export function resolveCursorAcpConfigUpdates(
428503 const fastOption = configOptions.find(
429504 (option) => option.category === "model_config" && isCursorFastConfigOption(option),
430505 );
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);
433509 if (value !== undefined) {
434510 updates.push({ configId: fastOption.id, value });
435511 }
......@@ -438,8 +514,9 @@ export function resolveCursorAcpConfigUpdates(
438514 const thinkingOption = configOptions.find(
439515 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
440516 );
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);
443520 if (value !== undefined) {
444521 updates.push({ configId: thinkingOption.id, value });
445522 }
......@@ -610,6 +687,7 @@ export function buildCursorProviderSnapshot(input: {
610687 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
611688 return buildServerProvider({
612689 provider: PROVIDER,
690 presentation: CURSOR_PRESENTATION,
613691 enabled: input.cursorSettings.enabled,
614692 checkedAt: input.checkedAt,
615693 models: providerModelsFromSettings(
......@@ -962,6 +1040,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
9621040 if (!cursorSettings.enabled) {
9631041 return buildServerProvider({
9641042 provider: PROVIDER,
1043 presentation: CURSOR_PRESENTATION,
9651044 enabled: false,
9661045 checkedAt,
9671046 models: fallbackModels,
......@@ -985,6 +1064,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
9851064 const error = aboutProbe.failure;
9861065 return buildServerProvider({
9871066 provider: PROVIDER,
1067 presentation: CURSOR_PRESENTATION,
9881068 enabled: cursorSettings.enabled,
9891069 checkedAt,
9901070 models: fallbackModels,
......@@ -1003,6 +1083,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
10031083 if (Option.isNone(aboutProbe.success)) {
10041084 return buildServerProvider({
10051085 provider: PROVIDER,
1086 presentation: CURSOR_PRESENTATION,
10061087 enabled: cursorSettings.enabled,
10071088 checkedAt,
10081089 models: fallbackModels,
......@@ -1025,6 +1106,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
10251106 if (parameterizedModelPickerUnsupportedMessage) {
10261107 return buildServerProvider({
10271108 provider: PROVIDER,
1109 presentation: CURSOR_PRESENTATION,
10281110 enabled: cursorSettings.enabled,
10291111 checkedAt,
10301112 models: fallbackModels,
apps/server/src/provider/Layers/OpenCodeAdapter.ts+3-2
......@@ -13,6 +13,7 @@ import {
1313} from "@t3tools/contracts";
1414import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect";
1515import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
16import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1617
1718import { resolveAttachmentPath } from "../../attachmentStore.ts";
1819import { ServerConfig } from "../../config.ts";
......@@ -1146,11 +1147,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
11461147
11471148 const agent =
11481149 input.modelSelection?.provider === PROVIDER
1149 ? input.modelSelection.options?.agent
1150 ? getModelSelectionStringOptionValue(input.modelSelection, "agent")
11501151 : undefined;
11511152 const variant =
11521153 input.modelSelection?.provider === PROVIDER
1153 ? input.modelSelection.options?.variant
1154 ? getModelSelectionStringOptionValue(input.modelSelection, "variant")
11541155 : undefined;
11551156
11561157 context.activeTurnId = turnId;
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+8-6
......@@ -176,14 +176,16 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
176176 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
177177
178178 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",
182181 );
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",
186186 );
187 assert.ok(agentDescriptor && agentDescriptor.type === "select");
188 assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build");
187189 }),
188190 );
189191});
apps/server/src/provider/Layers/OpenCodeProvider.ts+49-22
......@@ -6,6 +6,8 @@ import type {
66} from "@t3tools/contracts";
77import { Cause, Data, Effect, Equal, Layer, Stream } from "effect";
88
9import { createModelCapabilities } from "@t3tools/shared/model";
10
911import { ServerConfig } from "../../config.ts";
1012import { ServerSettingsService } from "../../serverSettings.ts";
1113import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
......@@ -25,6 +27,10 @@ import {
2527import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2628
2729const PROVIDER = "opencode" as const;
30const OPENCODE_PRESENTATION = {
31 displayName: "OpenCode",
32 showInteractionModeToggle: false,
33} as const;
2834const MINIMUM_OPENCODE_VERSION = "1.14.19";
2935
3036class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
......@@ -159,13 +165,9 @@ function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
159165 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
160166}
161167
162const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = {
163 reasoningEffortLevels: [],
164 supportsFastMode: false,
165 supportsThinkingToggle: false,
166 contextWindowOptions: [],
167 promptInjectedEffortLevels: [],
168};
168const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
169 optionDescriptors: [],
170});
169171
170172function openCodeCapabilitiesForModel(input: {
171173 readonly providerID: string;
......@@ -174,27 +176,46 @@ function openCodeCapabilitiesForModel(input: {
174176}): ModelCapabilities {
175177 const variantValues = Object.keys(input.model.variants ?? {});
176178 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) },
182183 );
183184 const primaryAgents = input.agents.filter(
184185 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
185186 );
186187 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) },
192192 );
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 });
198219}
199220
200221function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerProviderModel> {
......@@ -242,6 +263,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
242263 if (!openCodeSettings.enabled) {
243264 return buildServerProvider({
244265 provider: PROVIDER,
266 presentation: OPENCODE_PRESENTATION,
245267 enabled: false,
246268 checkedAt,
247269 models,
......@@ -260,6 +282,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
260282
261283 return buildServerProvider({
262284 provider: PROVIDER,
285 presentation: OPENCODE_PRESENTATION,
263286 enabled: true,
264287 checkedAt,
265288 models,
......@@ -296,6 +319,7 @@ export const OpenCodeProviderLive = Layer.effect(
296319 });
297320 return buildServerProvider({
298321 provider: PROVIDER,
322 presentation: OPENCODE_PRESENTATION,
299323 enabled: input.settings.enabled,
300324 checkedAt,
301325 models: providerModelsFromSettings(
......@@ -317,6 +341,7 @@ export const OpenCodeProviderLive = Layer.effect(
317341 if (!input.settings.enabled) {
318342 return buildServerProvider({
319343 provider: PROVIDER,
344 presentation: OPENCODE_PRESENTATION,
320345 enabled: false,
321346 checkedAt,
322347 models: providerModelsFromSettings(
......@@ -368,6 +393,7 @@ export const OpenCodeProviderLive = Layer.effect(
368393 if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
369394 return buildServerProvider({
370395 provider: PROVIDER,
396 presentation: OPENCODE_PRESENTATION,
371397 enabled: input.settings.enabled,
372398 checkedAt,
373399 models: providerModelsFromSettings(
......@@ -433,6 +459,7 @@ export const OpenCodeProviderLive = Layer.effect(
433459 const connectedCount = inventoryExit.value.providerList.connected.length;
434460 return buildServerProvider({
435461 provider: PROVIDER,
462 presentation: OPENCODE_PRESENTATION,
436463 enabled: true,
437464 checkedAt,
438465 models,
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+7-6
......@@ -19,6 +19,7 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1919import { CodexAdapter } from "../Services/CodexAdapter.ts";
2020import { CursorAdapter } from "../Services/CursorAdapter.ts";
2121import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
22import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts";
2223
2324export interface ProviderAdapterRegistryLiveOptions {
2425 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
......@@ -31,12 +32,12 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun
3132 const adapters =
3233 options?.adapters !== undefined
3334 ? 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 });
4041 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
4142
4243 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+61-32
......@@ -11,6 +11,7 @@ import {
1111import * as PlatformError from "effect/PlatformError";
1212import { ChildProcessSpawner } from "effect/unstable/process";
1313import { deepMerge } from "@t3tools/shared/Struct";
14import { createModelCapabilities } from "@t3tools/shared/model";
1415
1516import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
1617import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
......@@ -29,6 +30,30 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
2930
3031const encoder = new TextEncoder();
3132
33function 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
49function booleanDescriptor(id: string, label: string) {
50 return {
51 id,
52 label,
53 type: "boolean" as const,
54 };
55}
56
3257function mockHandle(result: { stdout: string; stderr: string; code: number }) {
3358 return ChildProcessSpawner.makeHandle({
3459 pid: ChildProcessSpawner.ProcessId(1),
......@@ -88,16 +113,15 @@ function failingSpawnerLayer(description: string) {
88113 );
89114}
90115
91const codexModelCapabilities = {
92 reasoningEffortLevels: [
93 { value: "high", label: "High", isDefault: true },
94 { value: "low", label: "Low" },
116const 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"),
95123 ],
96 supportsFastMode: true,
97 supportsThinkingToggle: false,
98 contextWindowOptions: [],
99 promptInjectedEffortLevels: [],
100} satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>;
124}) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>;
101125
102126function makeCodexProbeSnapshot(
103127 input: Partial<CodexAppServerProviderSnapshot> = {},
......@@ -330,13 +354,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
330354 slug: "claude-opus-4-6",
331355 name: "Opus 4.6",
332356 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 }),
340366 },
341367 ],
342368 slashCommands: [],
......@@ -367,13 +393,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
367393 slug: "claude-opus-4-6",
368394 name: "Opus 4.6",
369395 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 }),
377405 },
378406 ],
379407 slashCommands: [],
......@@ -387,13 +415,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
387415 slug: "claude-opus-4-6",
388416 name: "Opus 4.6",
389417 isCustom: false,
390 capabilities: {
391 reasoningEffortLevels: [],
392 supportsFastMode: false,
393 supportsThinkingToggle: false,
394 contextWindowOptions: [],
395 promptInjectedEffortLevels: [],
396 },
418 capabilities: createModelCapabilities({
419 optionDescriptors: [],
420 }),
397421 },
398422 ],
399423 } satisfies ServerProvider;
......@@ -603,9 +627,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
603627 "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.",
604628 );
605629 }
630 const effortDescriptor = opus47.capabilities.optionDescriptors?.find(
631 (descriptor) => descriptor.type === "select" && descriptor.id === "effort",
632 );
606633 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 },
609638 );
610639 }).pipe(
611640 Effect.provide(
apps/server/src/provider/Layers/ProviderRegistry.ts+9-38
......@@ -25,13 +25,8 @@ import {
2525 resolveProviderStatusCachePath,
2626 writeProviderStatusCache,
2727} from "../providerStatusCache.ts";
28
29type ProviderSnapshotSource = {
30 readonly provider: ProviderKind;
31 readonly getSnapshot: Effect.Effect<ServerProvider>;
32 readonly refresh: Effect.Effect<ServerProvider>;
33 readonly streamChanges: Stream.Stream<ServerProvider>;
34};
28import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts";
29import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
3530
3631const loadProviders = (
3732 providerSources: ReadonlyArray<ProviderSnapshotSource>,
......@@ -41,11 +36,7 @@ const loadProviders = (
4136 });
4237
4338const 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;
4940
5041const mergeProviderModels = (
5142 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
......@@ -98,32 +89,12 @@ const ProviderRegistryLiveBase = Layer.effect(
9889
9990 const cursorProvider = yield* CursorProvider;
10091
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>;
12798 const activeProviders = PROVIDER_CACHE_IDS;
12899 const changesPubSub = yield* Effect.acquireRelease(
129100 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
apps/server/src/provider/Layers/ProviderService.test.ts+8-14
......@@ -17,6 +17,7 @@ import {
1717 ThreadId,
1818 TurnId,
1919} from "@t3tools/contracts";
20import { createModelSelection } from "@t3tools/shared/model";
2021import { it, assert, vi } from "@effect/vitest";
2122
2223import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect";
......@@ -860,13 +861,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
860861 provider: "claudeAgent",
861862 threadId: asThreadId("thread-claude-send-turn"),
862863 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 ]),
870867 runtimeMode: "full-access",
871868 });
872869
......@@ -893,13 +890,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
893890 };
894891 assert.equal(startPayload.provider, "claudeAgent");
895892 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 );
903897 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
904898 assert.equal(startPayload.threadId, initial.threadId);
905899 }
apps/server/src/provider/acp/CursorAcpSupport.test.ts+5-5
......@@ -99,11 +99,11 @@ describe("applyCursorAcpModelSelection", () => {
9999 applyCursorAcpModelSelection({
100100 runtime,
101101 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 ],
107107 mapError: ({ step, configId, cause }) =>
108108 new Error(
109109 step === "set-config-option"
apps/server/src/provider/acp/CursorAcpSupport.ts+3-3
......@@ -1,4 +1,4 @@
1import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts";
1import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts";
22import { Effect, Layer, Scope } from "effect";
33import { ChildProcessSpawner } from "effect/unstable/process";
44import type * as EffectAcpErrors from "effect-acp/errors";
......@@ -76,7 +76,7 @@ interface CursorAcpModelSelectionRuntime {
7676export function applyCursorAcpModelSelection<E>(input: {
7777 readonly runtime: CursorAcpModelSelectionRuntime;
7878 readonly model: string | null | undefined;
79 readonly modelOptions: CursorModelOptions | null | undefined;
79 readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined;
8080 readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E;
8181}): Effect.Effect<void, E> {
8282 return Effect.gen(function* () {
......@@ -91,7 +91,7 @@ export function applyCursorAcpModelSelection<E>(input: {
9191
9292 const configUpdates = resolveCursorAcpConfigUpdates(
9393 yield* input.runtime.getConfigOptions,
94 input.modelOptions,
94 input.selections,
9595 );
9696 for (const update of configUpdates) {
9797 yield* input.runtime.setConfigOption(update.configId, update.value).pipe(
apps/server/src/provider/builtInProviderCatalog.ts created+49
......@@ -0,0 +1,49 @@
1import type { ProviderKind, ServerProvider } from "@t3tools/contracts";
2import type { Stream } from "effect";
3import type { ProviderAdapterError } from "./Errors.ts";
4import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
5import type { ServerProviderShape } from "./Services/ServerProvider.ts";
6
7export type ProviderSnapshotSource = {
8 readonly provider: ProviderKind;
9 readonly getSnapshot: ServerProviderShape["getSnapshot"];
10 readonly refresh: ServerProviderShape["refresh"];
11 readonly streamChanges: Stream.Stream<ServerProvider>;
12};
13
14type BuiltInProviderServiceMap = Record<ProviderKind, ServerProviderShape>;
15type BuiltInAdapterMap = {
16 readonly codex: ProviderAdapterShape<ProviderAdapterError>;
17 readonly claudeAgent: ProviderAdapterShape<ProviderAdapterError>;
18 readonly opencode: ProviderAdapterShape<ProviderAdapterError>;
19 readonly cursor?: ProviderAdapterShape<ProviderAdapterError>;
20};
21
22export const BUILT_IN_PROVIDER_ORDER = [
23 "codex",
24 "claudeAgent",
25 "opencode",
26 "cursor",
27] as const satisfies ReadonlyArray<ProviderKind>;
28
29export 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
40export 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 @@
11import { describe, it, assert } from "@effect/vitest";
22import type { ServerProvider } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";
34import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
45
56import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
67
8const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
9const fastModeCapabilities = createModelCapabilities({
10 optionDescriptors: [
11 {
12 id: "fastMode",
13 label: "Fast Mode",
14 type: "boolean",
15 },
16 ],
17});
18
719interface TestSettings {
820 readonly enabled: boolean;
921}
......@@ -43,13 +55,7 @@ const enrichedSnapshot: ServerProvider = {
4355 slug: "composer-2",
4456 name: "Composer 2",
4557 isCustom: false,
46 capabilities: {
47 reasoningEffortLevels: [],
48 supportsFastMode: true,
49 supportsThinkingToggle: false,
50 contextWindowOptions: [],
51 promptInjectedEffortLevels: [],
52 },
58 capabilities: fastModeCapabilities,
5359 },
5460 ],
5561};
......@@ -68,13 +74,7 @@ const enrichedSnapshotSecond: ServerProvider = {
6874 slug: "gpt-5.4",
6975 name: "GPT-5.4",
7076 isCustom: false,
71 capabilities: {
72 reasoningEffortLevels: [],
73 supportsFastMode: false,
74 supportsThinkingToggle: false,
75 contextWindowOptions: [],
76 promptInjectedEffortLevels: [],
77 },
77 capabilities: emptyCapabilities,
7878 },
7979 ],
8080};
apps/server/src/provider/opencodeRuntime.ts-1
......@@ -36,7 +36,6 @@ import { NetService } from "@t3tools/shared/Net";
3636const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
3737const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
3838const DEFAULT_HOSTNAME = "127.0.0.1";
39
4039export interface OpenCodeServerProcess {
4140 readonly url: string;
4241 readonly exitCode: Effect.Effect<number, never>;
apps/server/src/provider/providerSnapshot.test.ts+19-9
......@@ -1,17 +1,27 @@
11import { describe, expect, it } from "vitest";
22import type { ModelCapabilities } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";
34
45import { providerModelsFromSettings } from "./providerSnapshot.ts";
56
6const 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};
7const 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});
1525
1626describe("providerModelsFromSettings", () => {
1727 it("applies the provided capabilities to custom models", () => {
apps/server/src/provider/providerSnapshot.ts+55
......@@ -30,6 +30,12 @@ export interface ProviderProbeResult {
3030 readonly message?: string;
3131}
3232
33export interface ServerProviderPresentation {
34 readonly displayName: string;
35 readonly badgeLabel?: string;
36 readonly showInteractionModeToggle?: boolean;
37}
38
3339export function nonEmptyTrimmed(value: string | undefined): string | undefined {
3440 if (!value) return undefined;
3541 const trimmed = value.trim();
......@@ -129,8 +135,52 @@ export function providerModelsFromSettings(
129135 return [...resolvedBuiltInModels, ...customEntries];
130136}
131137
138export 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
166export 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
132181export function buildServerProvider(input: {
133182 provider: ServerProvider["provider"];
183 presentation: ServerProviderPresentation;
134184 enabled: boolean;
135185 checkedAt: string;
136186 models: ReadonlyArray<ServerProviderModel>;
......@@ -140,6 +190,11 @@ export function buildServerProvider(input: {
140190}): ServerProvider {
141191 return {
142192 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 : {}),
143198 enabled: input.enabled,
144199 installed: input.probe.installed,
145200 version: input.probe.version,
apps/server/src/provider/providerStatusCache.test.ts+6-21
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import type { ServerProvider } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";
34import { assert, it } from "@effect/vitest";
45import { Effect, FileSystem } from "effect";
56
......@@ -10,6 +11,8 @@ import {
1011 writeProviderStatusCache,
1112} from "./providerStatusCache.ts";
1213
14const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
15
1316const makeProvider = (
1417 provider: ServerProvider["provider"],
1518 overrides?: Partial<ServerProvider>,
......@@ -81,13 +84,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
8184 slug: "gpt-5-mini",
8285 name: "GPT-5 Mini",
8386 isCustom: false,
84 capabilities: {
85 reasoningEffortLevels: [],
86 supportsFastMode: false,
87 supportsThinkingToggle: false,
88 contextWindowOptions: [],
89 promptInjectedEffortLevels: [],
90 },
87 capabilities: emptyCapabilities,
9188 },
9289 ],
9390 message: "Cached message",
......@@ -106,13 +103,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
106103 slug: "gpt-5.4",
107104 name: "GPT-5.4",
108105 isCustom: false,
109 capabilities: {
110 reasoningEffortLevels: [],
111 supportsFastMode: false,
112 supportsThinkingToggle: false,
113 contextWindowOptions: [],
114 promptInjectedEffortLevels: [],
115 },
106 capabilities: emptyCapabilities,
116107 },
117108 ],
118109 message: "Pending refresh",
......@@ -131,13 +122,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
131122 slug: "gpt-5-mini",
132123 name: "GPT-5 Mini",
133124 isCustom: false,
134 capabilities: {
135 reasoningEffortLevels: [],
136 supportsFastMode: false,
137 supportsThinkingToggle: false,
138 contextWindowOptions: [],
139 promptInjectedEffortLevels: [],
140 },
125 capabilities: emptyCapabilities,
141126 },
142127 ],
143128 installed: cachedCodex.installed,
apps/server/src/serverSettings.test.ts+60-39
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts";
2import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts";
3import { createModelSelection } from "@t3tools/shared/model";
34import { assert, it } from "@effect/vitest";
45import { Effect, FileSystem, Layer, Schema } from "effect";
56import { ServerConfig } from "./config.ts";
......@@ -28,22 +29,40 @@ it.layer(NodeServices.layer)("server settings", (it) => {
2829 assert.deepEqual(
2930 decodePatch({
3031 textGenerationModelSelection: {
31 options: {
32 fastMode: false,
33 },
32 options: [{ id: "fastMode", value: false }],
3433 },
3534 }),
3635 {
3736 textGenerationModelSelection: {
38 options: {
39 fastMode: false,
40 },
37 options: [{ id: "fastMode", value: false }],
4138 },
4239 },
4340 );
4441 }),
4542 );
4643
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
4766 it.effect("deep merges nested settings updates without dropping siblings", () =>
4867 Effect.gen(function* () {
4968 const serverSettings = yield* ServerSettingsService;
......@@ -62,10 +81,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
6281 textGenerationModelSelection: {
6382 provider: "codex",
6483 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!,
6992 },
7093 });
7194
......@@ -76,9 +99,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
7699 },
77100 },
78101 textGenerationModelSelection: {
79 options: {
80 fastMode: false,
81 },
102 options: [{ id: "fastMode", value: false }],
82103 },
83104 });
84105
......@@ -94,14 +115,13 @@ it.layer(NodeServices.layer)("server settings", (it) => {
94115 customModels: ["claude-custom"],
95116 launchArgs: "",
96117 });
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 );
105125 }).pipe(Effect.provide(makeServerSettingsLayer())),
106126 );
107127
......@@ -114,9 +134,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
114134 textGenerationModelSelection: {
115135 provider: "claudeAgent",
116136 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!,
120140 },
121141 });
122142
......@@ -126,19 +146,16 @@ it.layer(NodeServices.layer)("server settings", (it) => {
126146 textGenerationModelSelection: {
127147 provider: "codex",
128148 model: "gpt-5.4",
129 options: {
130 reasoningEffort: "high",
131 },
149 options: createModelSelection("codex", "gpt-5.4", [
150 { id: "reasoningEffort", value: "high" },
151 ]).options!,
132152 },
133153 });
134154
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 );
142159 }).pipe(Effect.provide(makeServerSettingsLayer())),
143160 );
144161
......@@ -150,10 +167,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
150167 textGenerationModelSelection: {
151168 provider: "codex",
152169 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!,
157178 },
158179 });
159180
apps/web/src/components/ChatView.browser.tsx+52-75
......@@ -18,6 +18,7 @@ import {
1818 DEFAULT_SERVER_SETTINGS,
1919} from "@t3tools/contracts";
2020import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
21import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
2122import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
2223import { HttpResponse, http, ws } from "msw";
2324import { setupWorker } from "msw/browser";
......@@ -3777,14 +3778,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
37773778 it("snapshots sticky codex settings into a new draft thread", async () => {
37783779 useComposerDraftStore.setState({
37793780 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 ]),
37883785 },
37893786 stickyActiveProvider: "codex",
37903787 });
......@@ -3810,14 +3807,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
38103807 );
38113808 const newDraftId = draftIdFromPath(newThreadPath);
38123809
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.
38133814 expect(composerDraftFor(newDraftId)).toMatchObject({
38143815 modelSelectionByProvider: {
38153816 codex: {
38163817 provider: "codex",
38173818 model: "gpt-5.3-codex",
3818 options: {
3819 fastMode: true,
3820 },
3819 options: expect.arrayContaining([{ id: "fastMode", value: true }]),
38213820 },
38223821 },
38233822 activeProvider: "codex",
......@@ -3830,14 +3829,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
38303829 it("hydrates the provider alongside a sticky claude model", async () => {
38313830 useComposerDraftStore.setState({
38323831 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 ]),
38413836 },
38423837 stickyActiveProvider: "claudeAgent",
38433838 });
......@@ -3865,14 +3860,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
38653860
38663861 expect(composerDraftFor(newDraftId)).toMatchObject({
38673862 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 ]),
38763867 },
38773868 activeProvider: "claudeAgent",
38783869 });
......@@ -3912,14 +3903,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
39123903 it("prefers draft state over sticky composer settings and defaults", async () => {
39133904 useComposerDraftStore.setState({
39143905 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 ]),
39233910 },
39243911 stickyActiveProvider: "codex",
39253912 });
......@@ -3945,27 +3932,27 @@ describe("ChatView timeline estimator parity (full app)", () => {
39453932 );
39463933 const draftId = draftIdFromPath(threadPath);
39473934
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.
39483938 expect(composerDraftFor(draftId)).toMatchObject({
39493939 modelSelectionByProvider: {
39503940 codex: {
39513941 provider: "codex",
39523942 model: "gpt-5.3-codex",
3953 options: {
3954 fastMode: true,
3955 },
3943 options: expect.arrayContaining([{ id: "fastMode", value: true }]),
39563944 },
39573945 },
39583946 activeProvider: "codex",
39593947 });
39603948
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 );
39693956
39703957 await newThreadButton.click();
39713958
......@@ -3976,14 +3963,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
39763963 );
39773964 expect(composerDraftFor(draftId)).toMatchObject({
39783965 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 ]),
39873970 },
39883971 activeProvider: "codex",
39893972 });
......@@ -5670,37 +5653,31 @@ describe("ChatView timeline estimator parity (full app)", () => {
56705653 slug: "gpt-5.1-codex-max",
56715654 name: "GPT-5.1 Codex Max",
56725655 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 }),
56805661 },
56815662 {
56825663 slug: "gpt-5.3-codex",
56835664 name: "GPT-5.3 Codex",
56845665 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 }),
56925671 },
56935672 {
56945673 slug: "gpt-5.4",
56955674 name: "GPT-5.4",
56965675 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 }),
57045681 },
57055682 ],
57065683 },
apps/web/src/components/ChatView.tsx+7-6
......@@ -1,7 +1,6 @@
11import {
22 type ApprovalRequestId,
33 DEFAULT_MODEL_BY_PROVIDER,
4 type ClaudeAgentEffort,
54 type EnvironmentId,
65 type MessageId,
76 type ModelSelection,
......@@ -26,7 +25,11 @@ import {
2625 scopeProjectRef,
2726 scopeThreadRef,
2827} from "@t3tools/client-runtime";
29import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";
28import {
29 applyClaudePromptEffortPrefix,
30 createModelSelection,
31 resolvePromptInjectedEffort,
32} from "@t3tools/shared/model";
3033import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3134import { truncate } from "@t3tools/shared/String";
3235import { Debouncer } from "@tanstack/react-pacer";
......@@ -306,10 +309,8 @@ function formatOutgoingPrompt(params: {
306309 text: string;
307310}): string {
308311 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);
313314}
314315const SCRIPT_TERMINAL_COLS = 120;
315316const SCRIPT_TERMINAL_ROWS = 30;
apps/web/src/components/chat/ChatComposer.tsx+14-6
......@@ -71,11 +71,10 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
7171import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
7272import { searchSlashCommandItems } from "./composerSlashCommandSearch";
7373import {
74 getComposerProviderControls,
7574 getComposerProviderState,
7675 renderProviderTraitsMenuContent,
7776 renderProviderTraitsPicker,
78} from "./composerProviderRegistry";
77} from "./composerProviderState";
7978import { ContextWindowMeter } from "./ContextWindowMeter";
8079import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
8180import { basenameOfPath } from "../../vscode-icons";
......@@ -96,7 +95,11 @@ import {
9695 XIcon,
9796} from "lucide-react";
9897import { proposedPlanTitle } from "../../proposedPlan";
99import { resolveSelectableProvider, getProviderModels } from "../../providerModels";
98import {
99 getProviderInteractionModeToggle,
100 getProviderModels,
101 resolveSelectableProvider,
102} from "../../providerModels";
100103import type { UnifiedSettings } from "@t3tools/contracts/settings";
101104import type { SessionPhase, Thread } from "../../types";
102105import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
......@@ -595,7 +598,7 @@ export const ChatComposer = memo(
595598 model: selectedModel,
596599 models: selectedProviderModels,
597600 prompt,
598 modelOptions: composerModelOptions,
601 modelOptions: composerModelOptions?.[selectedProvider],
599602 }),
600603 [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels],
601604 );
......@@ -603,8 +606,13 @@ export const ChatComposer = memo(
603606 const selectedPromptEffort = composerProviderState.promptEffort;
604607 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
605608 const composerProviderControls = useMemo(
606 () => getComposerProviderControls(selectedProvider),
607 [selectedProvider],
609 () => ({
610 showInteractionModeToggle: getProviderInteractionModeToggle(
611 providerStatuses,
612 selectedProvider,
613 ),
614 }),
615 [providerStatuses, selectedProvider],
608616 );
609617 const selectedModelSelection = useMemo<ModelSelection>(
610618 () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch),
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+99-96
......@@ -10,6 +10,7 @@ import "../../index.css";
1010import { page } from "vitest/browser";
1111import { afterEach, describe, expect, it, vi } from "vitest";
1212import { render } from "vitest-browser-react";
13import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
1314
1415import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
1516import { TraitsMenuContent } from "./TraitsPicker";
......@@ -17,6 +18,34 @@ import { useComposerDraftStore } from "../../composerDraftStore";
1718
1819const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
1920
21function 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
41function booleanDescriptor(id: string, label: string) {
42 return {
43 id,
44 label,
45 type: "boolean" as const,
46 };
47}
48
2049async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
2150 const threadId = ThreadId.make("thread-compact-menu");
2251 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
......@@ -33,11 +62,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
3362 persistedAttachments: [],
3463 terminalContexts: [],
3564 modelSelectionByProvider: {
36 [provider]: {
37 provider,
38 model,
39 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
40 },
65 [provider]: createModelSelection(provider, model, props?.modelSelection?.options),
4166 },
4267 activeProvider: provider,
4368 runtimeMode: null,
......@@ -51,74 +76,58 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
5176 document.body.append(host);
5277 const onPromptChange = vi.fn();
5378 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 ];
122131 const screen = await render(
123132 <CompactComposerControlsMenu
124133 activePlan={false}
......@@ -169,7 +178,7 @@ describe("CompactComposerControlsMenu", () => {
169178
170179 it("shows fast mode controls for Opus", async () => {
171180 await using _ = await mountMenu({
172 modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" },
181 modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6"),
173182 });
174183
175184 await page.getByLabelText("More composer controls").click();
......@@ -177,14 +186,14 @@ describe("CompactComposerControlsMenu", () => {
177186 await vi.waitFor(() => {
178187 const text = document.body.textContent ?? "";
179188 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");
182191 });
183192 });
184193
185194 it("hides fast mode controls for non-Opus Claude models", async () => {
186195 await using _ = await mountMenu({
187 modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
196 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"),
188197 });
189198
190199 await page.getByLabelText("More composer controls").click();
......@@ -196,7 +205,7 @@ describe("CompactComposerControlsMenu", () => {
196205
197206 it("shows only the provided effort options", async () => {
198207 await using _ = await mountMenu({
199 modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
208 modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6"),
200209 });
201210
202211 await page.getByLabelText("More composer controls").click();
......@@ -213,11 +222,9 @@ describe("CompactComposerControlsMenu", () => {
213222
214223 it("shows a Claude thinking on/off section for Haiku", async () => {
215224 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 ]),
221228 });
222229
223230 await page.getByLabelText("More composer controls").click();
......@@ -225,18 +232,16 @@ describe("CompactComposerControlsMenu", () => {
225232 await vi.waitFor(() => {
226233 const text = document.body.textContent ?? "";
227234 expect(text).toContain("Thinking");
228 expect(text).toContain("On (default)");
235 expect(text).toContain("On");
229236 expect(text).toContain("Off");
230237 });
231238 });
232239
233240 it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => {
234241 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 ]),
240245 prompt: "Ultrathink:\nInvestigate this",
241246 });
242247
......@@ -244,18 +249,16 @@ describe("CompactComposerControlsMenu", () => {
244249
245250 await vi.waitFor(() => {
246251 const text = document.body.textContent ?? "";
247 expect(text).toContain("Effort");
252 expect(text).toContain("Reasoning");
248253 expect(text).not.toContain("ultrathink");
249254 });
250255 });
251256
252257 it("warns when ultrathink appears in prompt body text", async () => {
253258 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 ]),
259262 prompt: "Ultrathink:\nplease ultrathink about this problem",
260263 });
261264
......@@ -264,7 +267,7 @@ describe("CompactComposerControlsMenu", () => {
264267 await vi.waitFor(() => {
265268 const text = document.body.textContent ?? "";
266269 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.',
268271 );
269272 });
270273 });
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+151-107
......@@ -1,5 +1,6 @@
11import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
22import { EnvironmentId } from "@t3tools/contracts";
3import { createModelCapabilities } from "@t3tools/shared/model";
34import { page, userEvent } from "vitest/browser";
45import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56import { render } from "vitest-browser-react";
......@@ -66,17 +67,34 @@ vi.mock("../../environments/runtime", () => {
6667 };
6768});
6869
69function effort(value: string, isDefault = false) {
70function selectDescriptor(
71 id: string,
72 label: string,
73 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
74) {
7075 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
86function booleanDescriptor(id: string, label: string) {
87 return {
88 id,
89 label,
90 type: "boolean" as const,
7491 };
7592}
7693
7794const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
7895 {
7996 provider: "codex",
97 displayName: "Codex",
8098 enabled: true,
8199 installed: true,
82100 version: "0.116.0",
......@@ -90,30 +108,37 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
90108 slug: "gpt-5-codex",
91109 name: "GPT-5 Codex",
92110 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 }),
100121 },
101122 {
102123 slug: "gpt-5.3-codex",
103124 name: "GPT-5.3 Codex",
104125 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 }),
112136 },
113137 ],
114138 },
115139 {
116140 provider: "claudeAgent",
141 displayName: "Claude",
117142 enabled: true,
118143 installed: true,
119144 version: "1.0.0",
......@@ -127,47 +152,48 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
127152 slug: "claude-opus-4-6",
128153 name: "Claude Opus 4.6",
129154 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"),
136164 ],
137 supportsFastMode: false,
138 supportsThinkingToggle: true,
139 contextWindowOptions: [],
140 promptInjectedEffortLevels: [],
141 },
165 }),
142166 },
143167 {
144168 slug: "claude-sonnet-4-6",
145169 name: "Claude Sonnet 4.6",
146170 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"),
153180 ],
154 supportsFastMode: false,
155 supportsThinkingToggle: true,
156 contextWindowOptions: [],
157 promptInjectedEffortLevels: [],
158 },
181 }),
159182 },
160183 {
161184 slug: "claude-haiku-4-5",
162185 name: "Claude Haiku 4.5",
163186 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 }),
171197 },
172198 ],
173199 },
......@@ -176,6 +202,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
176202function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
177203 return {
178204 provider: "codex",
205 displayName: "Codex",
179206 enabled: true,
180207 installed: true,
181208 version: "0.116.0",
......@@ -464,13 +491,15 @@ describe("ProviderModelPicker", () => {
464491 subProvider: "GitHub Copilot",
465492 shortName: "Opus 4.5",
466493 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 }),
474503 },
475504 ]),
476505 ];
......@@ -658,13 +687,16 @@ describe("ProviderModelPicker", () => {
658687 slug: "gpt-5-codex",
659688 name: "GPT-5 Codex",
660689 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 }),
668700 },
669701 ]),
670702 buildOpenCodeProvider([
......@@ -673,13 +705,15 @@ describe("ProviderModelPicker", () => {
673705 name: "Claude Opus 4.7",
674706 subProvider: "GitHub Copilot",
675707 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 }),
683717 },
684718 ]),
685719 ];
......@@ -712,13 +746,15 @@ describe("ProviderModelPicker", () => {
712746 name: "Claude Opus 4.7",
713747 subProvider: "GitHub Copilot",
714748 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 }),
722758 },
723759 ]),
724760 {
......@@ -728,18 +764,17 @@ describe("ProviderModelPicker", () => {
728764 slug: "claude-opus-4-6",
729765 name: "Claude Opus 4.6",
730766 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"),
737776 ],
738 supportsFastMode: false,
739 supportsThinkingToggle: true,
740 contextWindowOptions: [],
741 promptInjectedEffortLevels: [],
742 },
777 }),
743778 },
744779 ],
745780 },
......@@ -909,13 +944,16 @@ describe("ProviderModelPicker", () => {
909944 slug: "gpt-5.3-codex",
910945 name: "GPT-5.3 Codex",
911946 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 }),
919957 },
920958 ]),
921959 TEST_PROVIDERS[1]!,
......@@ -926,25 +964,31 @@ describe("ProviderModelPicker", () => {
926964 slug: "gpt-5.3-codex",
927965 name: "GPT-5.3 Codex",
928966 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 }),
936977 },
937978 {
938979 slug: "gpt-5.3-codex-spark",
939980 name: "GPT-5.3 Codex Spark",
940981 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 }),
948992 },
949993 ]),
950994 TEST_PROVIDERS[1]!,
apps/web/src/components/chat/ProviderStatusBanner.tsx+3-2
......@@ -1,7 +1,8 @@
1import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts";
1import { type ServerProvider } from "@t3tools/contracts";
22import { memo } from "react";
33import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
44import { CircleAlertIcon } from "lucide-react";
5import { formatProviderKindLabel } from "../../providerModels";
56
67export const ProviderStatusBanner = memo(function ProviderStatusBanner({
78 status,
......@@ -12,7 +13,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
1213 return null;
1314 }
1415
15 const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider;
16 const providerLabel = status.displayName?.trim() || formatProviderKindLabel(status.provider);
1617 const defaultMessage =
1718 status.status === "error"
1819 ? `${providerLabel} provider is unavailable.`
apps/web/src/components/chat/TraitsPicker.browser.tsx deleted-821
......@@ -1,821 +0,0 @@
1import "../../index.css";
2
3import {
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";
15import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
16import { page } from "vitest/browser";
17import { useCallback } from "react";
18import { afterEach, describe, expect, it, vi } from "vitest";
19import { render } from "vitest-browser-react";
20
21import { TraitsPicker } from "./TraitsPicker";
22import {
23 COMPOSER_DRAFT_STORAGE_KEY,
24 ComposerThreadDraftState,
25 useComposerDraftStore,
26 useComposerThreadDraft,
27 useEffectiveComposerModelState,
28} from "../../composerDraftStore";
29import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
30
31// ── Claude TraitsPicker tests ─────────────────────────────────────────
32
33const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
34const CLAUDE_THREAD_ID = ThreadId.make("thread-claude-traits");
35const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID);
36const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF);
37const CODEX_THREAD_ID = ThreadId.make("thread-codex-traits");
38const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID);
39const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF);
40const 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];
163const 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
171function 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
210async 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
282describe("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
424async 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
479async 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
563describe("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
633async 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
693describe("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
756describe("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 @@
11import {
2 type ClaudeModelOptions,
3 type CodexModelOptions,
4 type CursorModelOptions,
5 type OpenCodeModelOptions,
62 type ProviderKind,
7 type ProviderModelOptions,
3 type ProviderOptionDescriptor,
4 type ProviderOptionSelection,
85 type ScopedThreadRef,
96 type ServerProviderModel,
107} from "@t3tools/contracts";
118import {
129 applyClaudePromptEffortPrefix,
10 buildProviderOptionSelectionsFromDescriptors,
11 getProviderOptionCurrentLabel,
12 getProviderOptionCurrentValue,
13 getProviderOptionDescriptors,
1314 isClaudeUltrathinkPrompt,
14 trimOrNull,
15 getDefaultEffort,
16 getDefaultContextWindow,
17 hasContextWindowOption,
18 resolveEffort,
1915} from "@t3tools/shared/model";
2016import { memo, useCallback, useState } from "react";
2117import type { VariantProps } from "class-variance-authority";
......@@ -34,12 +30,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
3430import { getProviderModelCapabilities } from "../../providerModels";
3531import { cn } from "~/lib/utils";
3632
37type ProviderOptions = ProviderModelOptions[ProviderKind];
38type NamedOption = {
39 value: string;
40 label: string;
41 isDefault?: boolean | undefined;
42};
33type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
4334
4435type TraitsPersistence =
4536 | {
......@@ -54,62 +45,34 @@ type TraitsPersistence =
5445
5546const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
5647
57function 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
73function 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
80function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
81 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
82}
83
84function 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;
48function 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 );
9566}
9667
97function getRawContextWindow(
98 provider: ProviderKind,
99 modelOptions: ProviderOptions | null | undefined,
68function getDescriptorStringValue(
69 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }> | null,
10070): string | null {
101 if (modelOptions && "contextWindow" in modelOptions) {
102 return trimOrNull(modelOptions.contextWindow);
71 if (!descriptor) {
72 return null;
10373 }
104 return null;
105}
106
107function 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;
11376}
11477
11578function getSelectedTraits(
......@@ -121,71 +84,68 @@ function getSelectedTraits(
12184 allowPromptInjectedEffort: boolean,
12285) {
12386 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;
160107
161108 // Prompt-controlled effort (e.g. ultrathink in prompt text)
162109 const ultrathinkPromptControlled =
163110 allowPromptInjectedEffort &&
164 caps.promptInjectedEffortLevels.length > 0 &&
111 (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 &&
165112 isClaudeUltrathinkPrompt(prompt);
166113
167114 // Check if "ultrathink" appears in the body text (not just our prefix)
168115 const ultrathinkInBodyText =
169116 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;
174130
175131 return {
176132 caps,
133 descriptors,
134 selectDescriptors,
135 booleanDescriptors,
136 primarySelectDescriptor,
137 contextWindowDescriptor,
138 agentDescriptor,
139 fastModeDescriptor,
140 thinkingDescriptor,
177141 effort,
178 effortLevels,
179142 thinkingEnabled,
180143 fastModeEnabled,
181 contextWindowOptions,
182144 contextWindow,
183 defaultContextWindow,
184145 ultrathinkPromptControlled,
185146 ultrathinkInBodyText,
186 agentOptions,
187 selectedAgent: selectedAgentOption?.value ?? null,
188 selectedAgentLabel: selectedAgentOption?.label ?? null,
147 selectedAgent,
148 selectedAgentLabel,
189149 };
190150}
191151
......@@ -206,11 +166,11 @@ function getTraitsSectionVisibility(input: {
206166 input.allowPromptInjectedEffort ?? true,
207167 );
208168
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;
214174
215175 return {
216176 ...selected,
......@@ -275,22 +235,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
275235 [model, persistence, provider, setProviderModelOptions],
276236 );
277237 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,
286242 ultrathinkPromptControlled,
287 showEffort,
288 showThinking,
289 showFastMode,
290 showContextWindow,
291243 ultrathinkInBodyText,
292 agentOptions,
293 selectedAgent,
294244 hasAnyControls,
295245 } = getTraitsSectionVisibility({
296246 provider,
......@@ -300,53 +250,30 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
300250 modelOptions,
301251 allowPromptInjectedEffort,
302252 });
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 };
310256
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 };
350277
351278 if (!hasAnyControls) {
352279 return null;
......@@ -354,121 +281,62 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
354281
355282 return (
356283 <>
357 {showsEffortSection ? (
358 <>
284 {selectDescriptors.map((descriptor, index) => (
285 <div key={descriptor.id}>
286 {index > 0 ? <MenuDivider /> : null}
359287 <MenuGroup>
360288 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
361 {provider === "opencode" ? "Variant" : "Effort"}
289 {descriptor.label}
362290 </div>
363 {ultrathinkInBodyText ? (
291 {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? (
364292 <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.
366295 </div>
367296 ) : null}
368297 <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)}
371304 >
372 {effortLevels.map((option) => (
305 {descriptor.options.map((option) => (
373306 <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}
377310 >
378311 {option.label}
379 {(provider === "opencode" ? option.isDefault : option.value === defaultEffort)
380 ? " (default)"
381 : ""}
312 {option.isDefault ? " (default)" : ""}
382313 </MenuRadioItem>
383314 ))}
384315 </MenuRadioGroup>
385316 </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}
427322 <MenuGroup>
428323 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
429 Context Window
324 {descriptor.label}
430325 </div>
431326 <MenuRadioGroup
432 value={contextWindow ?? defaultContextWindow ?? ""}
327 value={descriptor.currentValue === true ? "on" : "off"}
433328 onValueChange={(value) => {
434 updateModelOptions(
435 buildNextOptions(provider, modelOptions, {
436 contextWindow: value,
437 }),
329 updateDescriptors(
330 replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"),
438331 );
439332 }}
440333 >
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>
468336 </MenuRadioGroup>
469337 </MenuGroup>
470 </>
471 ) : null}
338 </div>
339 ))}
472340 </>
473341 );
474342});
......@@ -486,52 +354,15 @@ export const TraitsPicker = memo(function TraitsPicker({
486354 ...persistence
487355}: TraitsMenuContentProps & TraitsPersistence) {
488356 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 });
535366 if (
536367 !shouldRenderTraitsControls({
537368 provider,
......@@ -545,27 +376,22 @@ export const TraitsPicker = memo(function TraitsPicker({
545376 return null;
546377 }
547378
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(" · ") || "";
569395
570396 const isCodexStyle = provider === "codex";
571397
apps/web/src/components/chat/composerProviderRegistry.test.tsx deleted-516
......@@ -1,516 +0,0 @@
1import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
3import {
4 getComposerProviderControls,
5 getComposerProviderState,
6 renderProviderTraitsMenuContent,
7 renderProviderTraitsPicker,
8} from "./composerProviderRegistry";
9
10const 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
30const 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
79const 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
114const 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
137describe("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
473describe("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
490describe("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 @@
1import {
2 type ProviderKind,
3 type ProviderModelOptions,
4 type ScopedThreadRef,
5 type ServerProviderModel,
6} from "@t3tools/contracts";
7import {
8 isClaudeUltrathinkPrompt,
9 normalizeProviderModelOptionsWithCapabilities,
10 resolveEffort,
11 trimOrNull,
12} from "@t3tools/shared/model";
13import type { ReactNode } from "react";
14
15import type { DraftId } from "../../composerDraftStore";
16import { getProviderModelCapabilities } from "../../providerModels";
17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
18
19export type ComposerProviderStateInput = {
20 provider: ProviderKind;
21 model: string;
22 models: ReadonlyArray<ServerProviderModel>;
23 prompt: string;
24 modelOptions: ProviderModelOptions | null | undefined;
25};
26
27export 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
36type 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
46export type ComposerProviderControls = {
47 showInteractionModeToggle: boolean;
48};
49
50type ProviderRegistryEntry = {
51 controls: ComposerProviderControls;
52 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
53 renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode;
54 renderTraitsPicker: (input: TraitsRenderInput) => ReactNode;
55};
56
57function hasComposerTraitsTarget(input: {
58 threadRef: ScopedThreadRef | undefined;
59 draftId: DraftId | undefined;
60}): boolean {
61 return input.threadRef !== undefined || input.draftId !== undefined;
62}
63
64function 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
97function 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
140const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = {
141 showInteractionModeToggle: true,
142};
143
144function 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
159const 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
168export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
169 return composerProviderRegistry[input.provider].getState(input);
170}
171
172export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls {
173 return composerProviderRegistry[provider].controls;
174}
175
176export 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
197export 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 @@
1import { describe, expect, it } from "vitest";
2import type {
3 ProviderKind,
4 ProviderOptionDescriptor,
5 ProviderOptionSelection,
6 ServerProviderModel,
7} from "@t3tools/contracts";
8import {
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
18const PROVIDER: ProviderKind = "codex";
19const MODEL = "test-model";
20
21function 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
39function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
40 return { id, label: id, type: "boolean" };
41}
42
43function modelWith(
44 descriptors: ReadonlyArray<ProviderOptionDescriptor>,
45): ReadonlyArray<ServerProviderModel> {
46 return [
47 { slug: MODEL, name: MODEL, isCustom: false, capabilities: { optionDescriptors: descriptors } },
48 ];
49}
50
51function selections(
52 ...entries: Array<[string, string | boolean]>
53): ReadonlyArray<ProviderOptionSelection> {
54 return entries.map(([id, value]) => ({ id, value }));
55}
56
57const 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
63describe("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
225describe("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 @@
1import {
2 type ProviderKind,
3 type ProviderOptionSelection,
4 type ScopedThreadRef,
5 type ServerProviderModel,
6} from "@t3tools/contracts";
7import {
8 buildProviderOptionSelectionsFromDescriptors,
9 getProviderOptionCurrentValue,
10 getProviderOptionDescriptors,
11 isClaudeUltrathinkPrompt,
12} from "@t3tools/shared/model";
13import type { ReactNode } from "react";
14
15import type { DraftId } from "../../composerDraftStore";
16import { getProviderModelCapabilities } from "../../providerModels";
17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
18
19export type ComposerProviderStateInput = {
20 provider: ProviderKind;
21 model: string;
22 models: ReadonlyArray<ServerProviderModel>;
23 prompt: string;
24 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined;
25};
26
27export 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
36type 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
47export 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
75function 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
102export function renderProviderTraitsMenuContent(input: TraitsRenderInput): ReactNode {
103 return renderTraitsControl(TraitsMenuContent, input);
104}
105
106export 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 {
1111import { useQueryClient } from "@tanstack/react-query";
1212import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
1313import {
14 PROVIDER_DISPLAY_NAMES,
1514 type DesktopUpdateChannel,
1615 type ScopedThreadRef,
1716 type ProviderKind,
......@@ -20,8 +19,7 @@ import {
2019} from "@t3tools/contracts";
2120import { scopeThreadRef } from "@t3tools/client-runtime";
2221import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
23import { normalizeModelSlug } from "@t3tools/shared/model";
24import { createModelSelection } from "@t3tools/shared/model";
22import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2523import { Equal } from "effect";
2624import { APP_VERSION } from "../../branding";
2725import {
......@@ -79,6 +77,7 @@ import {
7977 useServerObservability,
8078 useServerProviders,
8179} from "../../rpc/serverState";
80import { formatProviderKindLabel } from "../../providerModels";
8281
8382const THEME_OPTIONS = [
8483 {
......@@ -1172,7 +1171,9 @@ export function GeneralSettingsPanel() {
11721171 const customModelInput = customModelInputByProvider[providerCard.provider];
11731172 const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
11741173 const providerDisplayName =
1175 PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title;
1174 providerCard.liveProvider?.displayName?.trim() ||
1175 providerCard.title ||
1176 formatProviderKindLabel(providerCard.provider);
11761177
11771178 return (
11781179 <div key={providerCard.provider} className="border-t border-border first:border-t-0">
......@@ -1471,11 +1472,22 @@ export function GeneralSettingsPanel() {
14711472 {providerCard.models.map((model) => {
14721473 const caps = model.capabilities;
14731474 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 }
14761482 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 )
14791491 ) {
14801492 capLabels.push("Reasoning");
14811493 }
apps/web/src/composerDraftStore.test.ts+77-64
......@@ -10,8 +10,38 @@ import {
1010 ProjectId,
1111 ThreadId,
1212 type ModelSelection,
13 type ProviderModelOptions,
13 type ProviderKind,
14 type ProviderOptionSelection,
1415} from "@t3tools/contracts";
16import { createModelSelection } from "@t3tools/shared/model";
17
18type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;
19type ProviderOptionSelectionsByProvider = Partial<Record<ProviderKind, ProviderOptionSelectionBag>>;
20
21function 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
34function 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}
1545import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1646
1747import {
......@@ -93,17 +123,15 @@ function resetComposerDraftStore() {
93123function modelSelection(
94124 provider: "codex" | "claudeAgent" | "cursor",
95125 model: string,
96 options?: ModelSelection["options"],
126 options?: Record<string, string | boolean | undefined>,
97127): ModelSelection {
98 return {
99 provider,
100 model,
101 ...(options ? { options } : {}),
102 } as ModelSelection;
128 return createModelSelection(provider, model, toSelections(options));
103129}
104130
105function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions {
106 return options;
131function providerModelOptions(
132 options: Partial<Record<ProviderKind, Record<string, string | boolean | undefined>>>,
133): ProviderOptionSelectionsByProvider {
134 return selectionsByProvider(options);
107135}
108136
109137const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
......@@ -941,14 +969,9 @@ describe("composerDraftStore modelSelection", () => {
941969 }),
942970 );
943971
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 });
952975
953976 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
954977 modelSelection("claudeAgent", "claude-opus-4-6", {
......@@ -972,9 +995,7 @@ describe("composerDraftStore modelSelection", () => {
972995 }),
973996 );
974997
975 store.setProviderModelOptions(threadRef, "claudeAgent", {
976 thinking: true,
977 });
998 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: true }));
978999
9791000 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
9801001 modelSelection("claudeAgent", "claude-opus-4-6", {
......@@ -989,10 +1010,11 @@ describe("composerDraftStore modelSelection", () => {
9891010
9901011 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true }));
9911012
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 );
9961018
9971019 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual(
9981020 modelSelection("codex", "gpt-5.4", {
......@@ -1014,11 +1036,11 @@ describe("composerDraftStore modelSelection", () => {
10141036 }),
10151037 );
10161038
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 );
10221044
10231045 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
10241046 modelSelection("cursor", "claude-opus-4-6", {
......@@ -1032,17 +1054,10 @@ describe("composerDraftStore modelSelection", () => {
10321054 it("preserves the selected Cursor model when only traits change", () => {
10331055 const store = useComposerDraftStore.getState();
10341056
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 });
10461061
10471062 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
10481063 modelSelection("cursor", "gpt-5.4", {
......@@ -1067,9 +1082,7 @@ describe("composerDraftStore modelSelection", () => {
10671082 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
10681083 );
10691084
1070 store.setProviderModelOptions(threadRef, "claudeAgent", {
1071 thinking: false,
1072 });
1085 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ thinking: false }));
10731086
10741087 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
10751088 modelSelection("claudeAgent", "claude-opus-4-6", {
......@@ -1097,8 +1110,13 @@ describe("composerDraftStore modelSelection", () => {
10971110 store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
10981111
10991112 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 );
11021120 });
11031121
11041122 it("preserves other provider options when switching the active model selection", () => {
......@@ -1118,7 +1136,9 @@ describe("composerDraftStore modelSelection", () => {
11181136 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
11191137 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
11201138 );
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 );
11221142 expect(draft?.activeProvider).toBe("claudeAgent");
11231143 });
11241144
......@@ -1127,14 +1147,9 @@ describe("composerDraftStore modelSelection", () => {
11271147
11281148 store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4"));
11291149
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 });
11381153
11391154 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
11401155 modelSelection("codex", "gpt-5.4", {
......@@ -1154,14 +1169,9 @@ describe("composerDraftStore modelSelection", () => {
11541169 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
11551170 );
11561171
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 });
11651175
11661176 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual(
11671177 modelSelection("claudeAgent", "claude-opus-4-6", {
......@@ -1279,12 +1289,15 @@ describe("composerDraftStore provider-scoped option updates", () => {
12791289 reasoningEffort: "medium",
12801290 }),
12811291 );
1282 store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" });
1292 store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" }));
12831293 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
12841294 expect(draft?.modelSelectionByProvider.codex).toEqual(
12851295 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
12861296 );
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 );
12881301 expect(draft?.activeProvider).toBe("codex");
12891302 });
12901303});
apps/web/src/composerDraftStore.ts+181-218
......@@ -1,16 +1,11 @@
11import {
2 CURSOR_REASONING_OPTIONS,
32 DEFAULT_MODEL_BY_PROVIDER,
4 type CursorModelOptions,
5 type CursorReasoningOption,
6 ClaudeAgentEffort,
7 CodexReasoningEffort,
83 type EnvironmentId,
94 ModelSelection,
105 ProjectId,
116 ProviderInteractionMode,
127 ProviderKind,
13 ProviderModelOptions,
8 ProviderOptionSelection,
149 RuntimeMode,
1510 type ServerProvider,
1611 type ScopedProjectRef,
......@@ -46,7 +41,7 @@ import { getDefaultServerModel } from "./providerModels";
4641import { UnifiedSettings } from "@t3tools/contracts/settings";
4742
4843export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
49const COMPOSER_DRAFT_STORAGE_VERSION = 5;
44const COMPOSER_DRAFT_STORAGE_VERSION = 6;
5045const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
5146const isRuntimeMode = Schema.is(RuntimeMode);
5247export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
......@@ -106,23 +101,30 @@ const PersistedComposerThreadDraftState = Schema.Struct({
106101});
107102type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type;
108103
109const LegacyCodexFields = Schema.Struct({
110 effort: Schema.optionalKey(CodexReasoningEffort),
111 codexFastMode: Schema.optionalKey(Schema.Boolean),
112 serviceTier: Schema.optionalKey(Schema.String),
113});
114type 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 */
109type ProviderOptionSelectionsByProvider = Partial<
110 Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>
111>;
115112
116const LegacyThreadModelFields = Schema.Struct({
117 provider: Schema.optionalKey(ProviderKind),
118 model: Schema.optionalKey(Schema.String),
119 modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),
120});
121type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type;
113type LegacyCodexFields = {
114 effort?: unknown;
115 codexFastMode?: unknown;
116 serviceTier?: unknown;
117};
118
119type LegacyThreadModelFields = {
120 provider?: unknown;
121 model?: unknown;
122 modelOptions?: unknown;
123};
122124
123125type LegacyV2ThreadDraftFields = {
124126 modelSelection?: ModelSelection | null;
125 modelOptions?: ProviderModelOptions | null;
127 modelOptions?: unknown;
126128};
127129
128130type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState &
......@@ -130,16 +132,15 @@ type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState
130132 LegacyThreadModelFields &
131133 LegacyV2ThreadDraftFields;
132134
133const LegacyStickyModelFields = Schema.Struct({
134 stickyProvider: Schema.optionalKey(ProviderKind),
135 stickyModel: Schema.optionalKey(Schema.String),
136 stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),
137});
138type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type;
135type LegacyStickyModelFields = {
136 stickyProvider?: unknown;
137 stickyModel?: unknown;
138 stickyModelOptions?: unknown;
139};
139140
140141type LegacyV2StoreFields = {
141142 stickyModelSelection?: ModelSelection | null;
142 stickyModelOptions?: ProviderModelOptions | null;
143 stickyModelOptions?: unknown;
143144 projectDraftThreadIdByProjectId?: Record<string, string> | null;
144145 draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null;
145146 draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null;
......@@ -334,15 +335,19 @@ interface ComposerDraftStoreState {
334335 threadRef: ComposerThreadTarget,
335336 modelSelection: ModelSelection | null | undefined,
336337 ) => void;
338 /** Replace the model options for one or more providers in the draft. */
337339 setModelOptions: (
338340 threadRef: ComposerThreadTarget,
339 modelOptions: ProviderModelOptions | null | undefined,
341 modelOptions:
342 | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>>
343 | null
344 | undefined,
340345 ) => void;
341346 applyStickyState: (threadRef: ComposerThreadTarget) => void;
342347 setProviderModelOptions: (
343348 threadRef: ComposerThreadTarget,
344349 provider: ProviderKind,
345 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
350 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
346351 options?: {
347352 model?: string | null | undefined;
348353 persistSticky?: boolean;
......@@ -379,7 +384,7 @@ interface ComposerDraftStoreState {
379384
380385export interface EffectiveComposerModelState {
381386 selectedModel: string;
382 modelOptions: ProviderModelOptions | null;
387 modelOptions: ProviderOptionSelectionsByProvider | null;
383388}
384389
385390interface ComposerDraftModelState {
......@@ -387,29 +392,48 @@ interface ComposerDraftModelState {
387392 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
388393}
389394
390function providerModelOptionsFromSelection(
395function providerSelectionsFromModelSelection(
391396 modelSelection: ModelSelection | null | undefined,
392): ProviderModelOptions | null {
393 if (!modelSelection?.options) {
397): ProviderOptionSelectionsByProvider | null {
398 if (!modelSelection) {
394399 return null;
395400 }
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;
400406}
401407
402408function modelSelectionByProviderToOptions(
403409 map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined,
404): ProviderModelOptions | null {
410): ProviderOptionSelectionsByProvider | null {
405411 if (!map) return null;
406 const result: Record<string, unknown> = {};
412 const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {};
407413 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;
410416 }
411417 }
412 return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null;
418 return Object.keys(result).length > 0 ? result : null;
419}
420
421function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelection> {
422 return {
423 ...selection,
424 ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}),
425 } as DeepMutable<ModelSelection>;
426}
427
428function 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>>>;
413437}
414438
415439const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
......@@ -536,149 +560,92 @@ function normalizeProviderKind(value: unknown): ProviderKind | null {
536560 : null;
537561}
538562
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 */
574function 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 */
539611function normalizeProviderModelOptions(
540612 value: unknown,
541613 provider?: ProviderKind | null,
542614 legacy?: LegacyCodexFields,
543): ProviderModelOptions | null {
615): ProviderOptionSelectionsByProvider | null {
544616 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 }
672624
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 }
675646 }
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;
682649}
683650
684651function normalizeModelSelection(
......@@ -703,6 +670,10 @@ function normalizeModelSelection(
703670 if (!model) {
704671 return null;
705672 }
673 if (Array.isArray(candidate?.options)) {
674 const selections = coerceProviderOptionSelections(candidate.options);
675 return createModelSelection(provider, model, selections);
676 }
706677 const modelOptions = normalizeProviderModelOptions(
707678 candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions,
708679 provider,
......@@ -716,7 +687,7 @@ function normalizeModelSelection(
716687
717688function legacySyncModelSelectionOptions(
718689 modelSelection: ModelSelection | null,
719 modelOptions: ProviderModelOptions | null | undefined,
690 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
720691): ModelSelection | null {
721692 if (modelSelection === null) {
722693 return null;
......@@ -727,9 +698,9 @@ function legacySyncModelSelectionOptions(
727698
728699function legacyMergeModelSelectionIntoProviderModelOptions(
729700 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) {
733704 return normalizeProviderModelOptions(currentModelOptions);
734705 }
735706 return legacyReplaceProviderModelOptions(
......@@ -740,35 +711,30 @@ function legacyMergeModelSelectionIntoProviderModelOptions(
740711}
741712
742713function legacyReplaceProviderModelOptions(
743 currentModelOptions: ProviderModelOptions | null | undefined,
714 currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined,
744715 provider: ProviderKind,
745 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
746): ProviderModelOptions | null {
716 nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
717): ProviderOptionSelectionsByProvider | null {
747718 const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } =
748719 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;
758725}
759726
760727// ── New helpers for the consolidated representation ────────────────────
761728
762729function legacyToModelSelectionByProvider(
763730 modelSelection: ModelSelection | null,
764 modelOptions: ProviderModelOptions | null | undefined,
731 modelOptions: ProviderOptionSelectionsByProvider | null | undefined,
765732): Partial<Record<ProviderKind, ModelSelection>> {
766733 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
767 // Add entries from the options bag (for non-active providers)
768734 if (modelOptions) {
769735 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
770736 const options = modelOptions[provider];
771 if (options && Object.keys(options).length > 0) {
737 if (options && options.length > 0) {
772738 result[provider] = createModelSelection(
773739 provider,
774740 modelSelection?.provider === provider
......@@ -779,7 +745,6 @@ function legacyToModelSelectionByProvider(
779745 }
780746 }
781747 }
782 // Add/overwrite the active selection (it's authoritative for its provider)
783748 if (modelSelection) {
784749 result[modelSelection.provider] = modelSelection;
785750 }
......@@ -813,8 +778,8 @@ export function deriveEffectiveComposerModelState(input: {
813778 : baseModel;
814779 const modelOptions =
815780 modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ??
816 providerModelOptionsFromSelection(input.threadModelSelection) ??
817 providerModelOptionsFromSelection(input.projectModelSelection) ??
781 providerSelectionsFromModelSelection(input.threadModelSelection) ??
782 providerSelectionsFromModelSelection(input.projectModelSelection) ??
818783 null;
819784
820785 return {
......@@ -1425,7 +1390,7 @@ function normalizePersistedDraftsByThreadId(
14251390 {
14261391 provider: legacyDraftCandidate.provider,
14271392 model: legacyDraftCandidate.model,
1428 modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions,
1393 modelOptions: normalizedModelOptions ?? (legacyDraftCandidate.modelOptions as unknown),
14291394 legacyCodex: legacyDraftCandidate,
14301395 },
14311396 );
......@@ -1472,7 +1437,12 @@ function normalizePersistedDraftsByThreadId(
14721437 prompt,
14731438 attachments,
14741439 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
1475 ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}),
1440 ...(hasModelData
1441 ? {
1442 modelSelectionByProvider: cloneModelSelectionByProvider(modelSelectionByProvider),
1443 activeProvider,
1444 }
1445 : {}),
14761446 ...(runtimeMode ? { runtimeMode } : {}),
14771447 ...(interactionMode ? { interactionMode } : {}),
14781448 };
......@@ -1573,7 +1543,7 @@ function partializeComposerDraftStoreState(
15731543 : {}),
15741544 ...(hasModelData
15751545 ? {
1576 modelSelectionByProvider: draft.modelSelectionByProvider,
1546 modelSelectionByProvider: cloneModelSelectionByProvider(draft.modelSelectionByProvider),
15771547 activeProvider: draft.activeProvider,
15781548 }
15791549 : {}),
......@@ -2304,27 +2274,24 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23042274 if (threadKey.length === 0) {
23052275 return;
23062276 }
2307 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
23082277 set((state) => {
23092278 const existing = state.draftsByThreadKey[threadKey];
2310 if (!existing && normalizedOpts === null) {
2279 if (!existing && (!modelOptions || Object.keys(modelOptions).length === 0)) {
23112280 return state;
23122281 }
23132282 const base = existing ?? createEmptyThreadDraft();
23142283 const nextMap = { ...base.modelSelectionByProvider };
23152284 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];
23192287 const current = nextMap[provider];
2320 if (opts) {
2288 if (opts && opts.length > 0) {
23212289 nextMap[provider] = createModelSelection(
23222290 provider,
23232291 current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
23242292 opts,
23252293 );
23262294 } else if (current?.options) {
2327 // Remove options but keep the selection
23282295 const { options: _, ...rest } = current;
23292296 nextMap[provider] = rest as ModelSelection;
23302297 }
......@@ -2357,12 +2324,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23572324 const fallbackModel =
23582325 normalizeModelSlug(options?.model, normalizedProvider) ??
23592326 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;
23662329
23672330 set((state) => {
23682331 const existing = state.draftsByThreadKey[threadKey];
......@@ -2377,7 +2340,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23772340 currentForProvider?.model ?? fallbackModel,
23782341 providerOpts,
23792342 );
2380 } else if (currentForProvider?.options) {
2343 } else if (currentForProvider && (currentForProvider.options?.length ?? 0) > 0) {
23812344 const { options: _, ...rest } = currentForProvider;
23822345 nextMap[normalizedProvider] = rest as ModelSelection;
23832346 }
......@@ -2397,7 +2360,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
23972360 stickyBase.model,
23982361 providerOpts,
23992362 );
2400 } else if (stickyBase.options) {
2363 } else if ((stickyBase.options?.length ?? 0) > 0) {
24012364 const { options: _, ...rest } = stickyBase;
24022365 nextStickyMap[normalizedProvider] = rest as ModelSelection;
24032366 }
apps/web/src/modelSelection.ts+2-45
......@@ -9,7 +9,7 @@ import {
99 normalizeModelSlug,
1010 resolveSelectableModel,
1111} from "@t3tools/shared/model";
12import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
12import { getComposerProviderState } from "./components/chat/composerProviderState";
1313import { UnifiedSettings } from "@t3tools/contracts/settings";
1414import {
1515 getDefaultServerModel,
......@@ -21,14 +21,6 @@ import { ModelEsque } from "./components/chat/providerIconUtils";
2121const MAX_CUSTOM_MODEL_COUNT = 32;
2222export const MAX_CUSTOM_MODEL_LENGTH = 256;
2323
24export type ProviderCustomModelConfig = {
25 provider: ProviderKind;
26 title: string;
27 description: string;
28 placeholder: string;
29 example: string;
30};
31
3224export interface AppModelOption {
3325 slug: string;
3426 name: string;
......@@ -37,39 +29,6 @@ export interface AppModelOption {
3729 isCustom: boolean;
3830}
3931
40const 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
71export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
72
7332export function normalizeCustomModelSlugs(
7433 models: Iterable<string | null | undefined>,
7534 builtInModelSlugs: ReadonlySet<string>,
......@@ -222,9 +181,7 @@ export function resolveAppModelSelectionState(
222181 model,
223182 models: getProviderModels(providers, provider),
224183 prompt: "",
225 modelOptions: {
226 [provider]: provider === selection.provider ? selection.options : undefined,
227 },
184 modelOptions: provider === selection.provider ? selection.options : undefined,
228185 });
229186
230187 return createModelSelection(provider, model, modelOptionsForDispatch);
apps/web/src/providerModels.ts+27-41
......@@ -1,25 +1,23 @@
11import {
22 DEFAULT_MODEL_BY_PROVIDER,
3 type CursorModelOptions,
43 type ModelCapabilities,
54 type ProviderKind,
65 type ServerProvider,
76 type ServerProviderModel,
87} from "@t3tools/contracts";
9import {
10 hasEffortLevel,
11 normalizeModelSlug,
12 resolveContextWindow,
13 trimOrNull,
14} from "@t3tools/shared/model";
8import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model";
9
10const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
11 optionDescriptors: [],
12});
1513
16const EMPTY_CAPABILITIES: ModelCapabilities = {
17 reasoningEffortLevels: [],
18 supportsFastMode: false,
19 supportsThinkingToggle: false,
20 contextWindowOptions: [],
21 promptInjectedEffortLevels: [],
22};
14export 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}
2321
2422export function getProviderModels(
2523 providers: ReadonlyArray<ServerProvider>,
......@@ -35,6 +33,21 @@ export function getProviderSnapshot(
3533 return providers.find((candidate) => candidate.provider === provider);
3634}
3735
36export 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
44export function getProviderInteractionModeToggle(
45 providers: ReadonlyArray<ServerProvider>,
46 provider: ProviderKind,
47): boolean {
48 return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true;
49}
50
3851export function isProviderEnabled(
3952 providers: ReadonlyArray<ServerProvider>,
4053 provider: ProviderKind,
......@@ -76,30 +89,3 @@ export function getDefaultServerModel(
7689 DEFAULT_MODEL_BY_PROVIDER[provider]
7790 );
7891}
79
80export 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 @@
1import { Schema } from "effect";
1import { Effect, Schema, SchemaTransformation } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33import type { ProviderKind } from "./orchestration.ts";
44
5export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
6export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS);
7export type CodexReasoningEffort = typeof CodexReasoningEffort.Type;
8export const CLAUDE_AGENT_EFFORT_OPTIONS = [
9 "low",
10 "medium",
11 "high",
12 "xhigh",
13 "max",
14 "ultrathink",
15] as const;
16export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS);
17export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type;
18export type ClaudeCodeEffort = ClaudeAgentEffort;
19export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const;
20export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS);
21export type CursorReasoningOption = typeof CursorReasoningOption.Type;
22
23export type ProviderReasoningEffort =
24 | CodexReasoningEffort
25 | ClaudeAgentEffort
26 | CursorReasoningOption;
27
28export const CodexModelOptions = Schema.Struct({
29 reasoningEffort: Schema.optional(CodexReasoningEffort),
30 fastMode: Schema.optional(Schema.Boolean),
31});
32export type CodexModelOptions = typeof CodexModelOptions.Type;
5export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]);
6export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type;
337
34export 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),
8export const ProviderOptionChoice = Schema.Struct({
9 id: TrimmedNonEmptyString,
10 label: TrimmedNonEmptyString,
11 description: Schema.optional(TrimmedNonEmptyString),
12 isDefault: Schema.optional(Schema.Boolean),
3913});
40export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
14export type ProviderOptionChoice = typeof ProviderOptionChoice.Type;
4115
42export 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});
48export type CursorModelOptions = typeof CursorModelOptions.Type;
49export const OpenCodeModelOptions = Schema.Struct({
50 variant: Schema.optional(TrimmedNonEmptyString),
51 agent: Schema.optional(TrimmedNonEmptyString),
52});
53export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type;
16const ProviderOptionDescriptorBase = {
17 id: TrimmedNonEmptyString,
18 label: TrimmedNonEmptyString,
19 description: Schema.optional(TrimmedNonEmptyString),
20} as const;
5421
55export const ProviderModelOptions = Schema.Struct({
56 codex: Schema.optional(CodexModelOptions),
57 claudeAgent: Schema.optional(ClaudeModelOptions),
58 cursor: Schema.optional(CursorModelOptions),
59 opencode: Schema.optional(OpenCodeModelOptions),
22export 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)),
6028});
61export type ProviderModelOptions = typeof ProviderModelOptions.Type;
29export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type;
6230
63export const EffortOption = Schema.Struct({
64 value: TrimmedNonEmptyString,
65 label: TrimmedNonEmptyString,
66 isDefault: Schema.optional(Schema.Boolean),
31export const BooleanProviderOptionDescriptor = Schema.Struct({
32 ...ProviderOptionDescriptorBase,
33 type: Schema.Literal("boolean"),
34 currentValue: Schema.optional(Schema.Boolean),
6735});
68export type EffortOption = typeof EffortOption.Type;
36export type BooleanProviderOptionDescriptor = typeof BooleanProviderOptionDescriptor.Type;
6937
70export const ContextWindowOption = Schema.Struct({
71 value: TrimmedNonEmptyString,
72 label: TrimmedNonEmptyString,
73 isDefault: Schema.optional(Schema.Boolean),
38export const ProviderOptionDescriptor = Schema.Union([
39 SelectProviderOptionDescriptor,
40 BooleanProviderOptionDescriptor,
41]);
42export type ProviderOptionDescriptor = typeof ProviderOptionDescriptor.Type;
43
44export const ProviderOptionSelectionValue = Schema.Union([TrimmedNonEmptyString, Schema.Boolean]);
45export type ProviderOptionSelectionValue = typeof ProviderOptionSelectionValue.Type;
46
47export const ProviderOptionSelection = Schema.Struct({
48 id: TrimmedNonEmptyString,
49 value: ProviderOptionSelectionValue,
7450});
75export type ContextWindowOption = typeof ContextWindowOption.Type;
51export 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 */
64const LegacyProviderOptionSelectionsObject = Schema.Record(Schema.String, Schema.Unknown);
65
66const 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 */
88export const ProviderOptionSelections = Schema.Union([
89 Schema.Array(ProviderOptionSelection),
90 ProviderOptionSelectionsFromLegacyObject,
91]);
92export type ProviderOptionSelections = typeof ProviderOptionSelections.Type;
93
94function 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
113function 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}
76122
77123export 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)),
85125});
86126export type ModelCapabilities = typeof ModelCapabilities.Type;
87127
packages/contracts/src/orchestration.test.ts+89-4
......@@ -33,6 +33,13 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
3333const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn);
3434const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
3535const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
36
37function getOptionValue(
38 options: ReadonlyArray<{ id: string; value: unknown }> | undefined,
39 id: string,
40): unknown {
41 return options?.find((option) => option.id === id)?.value;
42}
3643const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
3744const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
3845const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
......@@ -364,19 +371,97 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
364371 modelSelection: {
365372 provider: "codex",
366373 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
387it.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",
367396 options: {
368 reasoningEffort: "high",
397 effort: "max",
369398 fastMode: true,
399 // Falsy/garbage entries are dropped, matching migration 026.
400 emptyStr: " ",
401 nullish: null,
402 nested: { foo: 1 },
370403 },
371404 },
405 branch: null,
406 worktreePath: null,
372407 createdAt: "2026-01-01T00:00:00.000Z",
408 updatedAt: "2026-01-01T00:00:00.000Z",
373409 });
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
419it.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 ]);
377438 }),
378439);
379440
441it.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
380465it.effect("accepts a title seed in thread.turn.start", () =>
381466 Effect.gen(function* () {
382467 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+5-10
......@@ -1,10 +1,5 @@
11import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2import {
3 ClaudeModelOptions,
4 CodexModelOptions,
5 CursorModelOptions,
6 OpenCodeModelOptions,
7} from "./model.ts";
2import { ProviderOptionSelections } from "./model.ts";
83import { RepositoryIdentity } from "./environment.ts";
94import {
105 ApprovalRequestId,
......@@ -51,27 +46,27 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
5146export const CodexModelSelection = Schema.Struct({
5247 provider: Schema.Literal("codex"),
5348 model: TrimmedNonEmptyString,
54 options: Schema.optionalKey(CodexModelOptions),
49 options: Schema.optionalKey(ProviderOptionSelections),
5550});
5651export type CodexModelSelection = typeof CodexModelSelection.Type;
5752
5853export const ClaudeModelSelection = Schema.Struct({
5954 provider: Schema.Literal("claudeAgent"),
6055 model: TrimmedNonEmptyString,
61 options: Schema.optionalKey(ClaudeModelOptions),
56 options: Schema.optionalKey(ProviderOptionSelections),
6257});
6358export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
6459
6560export const CursorModelSelection = Schema.Struct({
6661 provider: Schema.Literal("cursor"),
6762 model: TrimmedNonEmptyString,
68 options: Schema.optionalKey(CursorModelOptions),
63 options: Schema.optionalKey(ProviderOptionSelections),
6964});
7065export type CursorModelSelection = typeof CursorModelSelection.Type;
7166export const OpenCodeModelSelection = Schema.Struct({
7267 provider: Schema.Literal("opencode"),
7368 model: TrimmedNonEmptyString,
74 options: Schema.optionalKey(OpenCodeModelOptions),
69 options: Schema.optionalKey(ProviderOptionSelections),
7570});
7671export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;
7772
packages/contracts/src/provider.test.ts+35-28
......@@ -6,6 +6,13 @@ import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts"
66const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
77const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
88
9function 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
916describe("ProviderSessionStartInput", () => {
1017 it("accepts codex-compatible payloads", () => {
1118 const parsed = decodeProviderSessionStartInput({
......@@ -15,10 +22,10 @@ describe("ProviderSessionStartInput", () => {
1522 modelSelection: {
1623 provider: "codex",
1724 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 ],
2229 },
2330 runtimeMode: "full-access",
2431 });
......@@ -28,8 +35,8 @@ describe("ProviderSessionStartInput", () => {
2835 if (parsed.modelSelection?.provider !== "codex") {
2936 throw new Error("Expected codex modelSelection");
3037 }
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);
3340 });
3441
3542 it("rejects payloads without runtime mode", () => {
......@@ -49,11 +56,11 @@ describe("ProviderSessionStartInput", () => {
4956 modelSelection: {
5057 provider: "claudeAgent",
5158 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 ],
5764 },
5865 runtimeMode: "full-access",
5966 });
......@@ -63,9 +70,9 @@ describe("ProviderSessionStartInput", () => {
6370 if (parsed.modelSelection?.provider !== "claudeAgent") {
6471 throw new Error("Expected claude modelSelection");
6572 }
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);
6976 expect(parsed.runtimeMode).toBe("full-access");
7077 });
7178
......@@ -78,14 +85,14 @@ describe("ProviderSessionStartInput", () => {
7885 modelSelection: {
7986 provider: "cursor",
8087 model: "composer-2",
81 options: { fastMode: true },
88 options: [{ id: "fastMode", value: true }],
8289 },
8390 });
8491 expect(parsed.provider).toBe("cursor");
8592 expect(parsed.modelSelection?.provider).toBe("cursor");
8693 expect(parsed.modelSelection?.model).toBe("composer-2");
8794 if (parsed.modelSelection?.provider === "cursor") {
88 expect(parsed.modelSelection.options?.fastMode).toBe(true);
95 expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true);
8996 }
9097 });
9198});
......@@ -97,10 +104,10 @@ describe("ProviderSendTurnInput", () => {
97104 modelSelection: {
98105 provider: "codex",
99106 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 ],
104111 },
105112 });
106113
......@@ -109,8 +116,8 @@ describe("ProviderSendTurnInput", () => {
109116 if (parsed.modelSelection?.provider !== "codex") {
110117 throw new Error("Expected codex modelSelection");
111118 }
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);
114121 });
115122
116123 it("accepts claude modelSelection including ultrathink", () => {
......@@ -119,10 +126,10 @@ describe("ProviderSendTurnInput", () => {
119126 modelSelection: {
120127 provider: "claudeAgent",
121128 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 ],
126133 },
127134 });
128135
......@@ -130,7 +137,7 @@ describe("ProviderSendTurnInput", () => {
130137 if (parsed.modelSelection?.provider !== "claudeAgent") {
131138 throw new Error("Expected claude modelSelection");
132139 }
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);
135142 });
136143});
packages/contracts/src/server.ts+3
......@@ -85,6 +85,9 @@ export type ServerProviderSkill = typeof ServerProviderSkill.Type;
8585
8686export const ServerProvider = Schema.Struct({
8787 provider: ProviderKind,
88 displayName: Schema.optional(TrimmedNonEmptyString),
89 badgeLabel: Schema.optional(TrimmedNonEmptyString),
90 showInteractionModeToggle: Schema.optional(Schema.Boolean),
8891 enabled: Schema.Boolean,
8992 installed: Schema.Boolean,
9093 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+5-32
......@@ -3,11 +3,8 @@ import * as Schema from "effect/Schema";
33import * as SchemaTransformation from "effect/SchemaTransformation";
44import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
55import {
6 ClaudeModelOptions,
7 CodexModelOptions,
8 CursorModelOptions,
96 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
10 OpenCodeModelOptions,
7 ProviderOptionSelections,
118} from "./model.ts";
129import { ModelSelection, ProviderKind } from "./orchestration.ts";
1310
......@@ -170,50 +167,26 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = {
170167
171168// ── Server Settings Patch (replace with a Schema.deepPartial if available) ──────────────────────────────────────────
172169
173const CodexModelOptionsPatch = Schema.Struct({
174 reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort),
175 fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode),
176});
177
178const 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
185const 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
192const OpenCodeModelOptionsPatch = Schema.Struct({
193 variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),
194 agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),
195});
196
197170const ModelSelectionPatch = Schema.Union([
198171 Schema.Struct({
199172 provider: Schema.optionalKey(Schema.Literal("codex")),
200173 model: Schema.optionalKey(TrimmedNonEmptyString),
201 options: Schema.optionalKey(CodexModelOptionsPatch),
174 options: Schema.optionalKey(ProviderOptionSelections),
202175 }),
203176 Schema.Struct({
204177 provider: Schema.optionalKey(Schema.Literal("claudeAgent")),
205178 model: Schema.optionalKey(TrimmedNonEmptyString),
206 options: Schema.optionalKey(ClaudeModelOptionsPatch),
179 options: Schema.optionalKey(ProviderOptionSelections),
207180 }),
208181 Schema.Struct({
209182 provider: Schema.optionalKey(Schema.Literal("cursor")),
210183 model: Schema.optionalKey(TrimmedNonEmptyString),
211 options: Schema.optionalKey(CursorModelOptionsPatch),
184 options: Schema.optionalKey(ProviderOptionSelections),
212185 }),
213186 Schema.Struct({
214187 provider: Schema.optionalKey(Schema.Literal("opencode")),
215188 model: Schema.optionalKey(TrimmedNonEmptyString),
216 options: Schema.optionalKey(OpenCodeModelOptionsPatch),
189 options: Schema.optionalKey(ProviderOptionSelections),
217190 }),
218191]);
219192
packages/shared/src/model.test.ts+124-158
......@@ -3,46 +3,67 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/cont
33
44import {
55 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,
1014 isClaudeUltrathinkPrompt,
11 normalizeClaudeModelOptionsWithCapabilities,
12 normalizeCodexModelOptionsWithCapabilities,
1315 normalizeModelSlug,
14 resolveContextWindow,
15 resolveEffort,
1616 resolveModelSlugForProvider,
1717 resolveSelectableModel,
1818 trimOrNull,
1919} from "./model.ts";
2020
21const codexCaps: ModelCapabilities = {
22 reasoningEffortLevels: [
23 { value: "xhigh", label: "Extra High" },
24 { value: "high", label: "High", isDefault: true },
21const 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 },
2538 ],
26 supportsFastMode: true,
27 supportsThinkingToggle: false,
28 contextWindowOptions: [],
29 promptInjectedEffortLevels: [],
30};
31
32const 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
41const 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 },
4365 ],
44 promptInjectedEffortLevels: ["ultrathink"],
45};
66});
4667
4768describe("normalizeModelSlug", () => {
4869 it("maps known aliases to canonical slugs", () => {
......@@ -86,54 +107,6 @@ describe("resolveSelectableModel", () => {
86107 });
87108});
88109
89describe("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
101describe("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
137110describe("misc helpers", () => {
138111 it("detects ultrathink prompts", () => {
139112 expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true);
......@@ -156,95 +129,88 @@ describe("misc helpers", () => {
156129 });
157130});
158131
159describe("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
176describe("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
198describe("normalize*ModelOptionsWithCapabilities", () => {
199 it("preserves explicit false codex fast mode", () => {
132describe("descriptor helpers", () => {
133 it("applies selection values to capability descriptors", () => {
200134 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 ],
204141 }),
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 ],
208175 });
176
177 expect(buildProviderOptionSelectionsFromDescriptors(descriptors)).toEqual([
178 { id: "reasoningEffort", value: "high" },
179 { id: "fastMode", value: true },
180 ]);
209181 });
210182
211 it("preserves the default Claude context window explicitly", () => {
183 it("stores option selection arrays in model selections", () => {
212184 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 ]),
226189 ).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 ],
229196 });
230197 });
231198
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);
233210 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);
249215 });
250216});
packages/shared/src/model.ts+216-153
......@@ -1,15 +1,11 @@
11import {
22 DEFAULT_MODEL_BY_PROVIDER,
33 MODEL_SLUG_ALIASES_BY_PROVIDER,
4 type ClaudeAgentEffort,
5 type ClaudeModelOptions,
6 type CodexModelOptions,
7 type CursorModelOptions,
84 type ModelCapabilities,
95 type ModelSelection,
10 type OpenCodeModelOptions,
6 type ProviderOptionDescriptor,
7 type ProviderOptionSelection,
118 type ProviderKind,
12 type ProviderModelOptions,
139} from "@t3tools/contracts";
1410
1511export interface SelectableModelOption {
......@@ -17,157 +13,215 @@ export interface SelectableModelOption {
1713 name: string;
1814}
1915
20/** Check whether a capabilities object includes a given effort value. */
21export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean {
22 return caps.reasoningEffortLevels.some((l) => l.value === value);
16export function createModelCapabilities(input: {
17 optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
18}): ModelCapabilities {
19 return {
20 optionDescriptors: input.optionDescriptors.map(cloneDescriptor),
21 };
2322}
2423
25/** Return the default effort value for a capabilities object, or null if none. */
26export function getDefaultEffort(caps: ModelCapabilities): string | null {
27 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
24function 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;
2830}
2931
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 */
37export function resolveEffort(
38 caps: ModelCapabilities,
32export function getProviderOptionSelectionValue(
33 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
34 id: string,
35): string | boolean | undefined {
36 return getRawSelectionValueById(selections, id);
37}
38
39export 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
47export 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
55export function getModelSelectionOptionValue(
56 modelSelection: ModelSelection | null | undefined,
57 id: string,
58): string | boolean | undefined {
59 return getProviderOptionSelectionValue(modelSelection?.options, id);
60}
61
62export function getModelSelectionStringOptionValue(
63 modelSelection: ModelSelection | null | undefined,
64 id: string,
65): string | undefined {
66 return getProviderOptionStringSelectionValue(modelSelection?.options, id);
67}
68
69export function getModelSelectionBooleanOptionValue(
70 modelSelection: ModelSelection | null | undefined,
71 id: string,
72): boolean | undefined {
73 return getProviderOptionBooleanSelectionValue(modelSelection?.options, id);
74}
75
76function resolveDescriptorChoiceValue(
77 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
3978 raw: string | null | undefined,
4079): 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 }
4387 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)
4790 ) {
91 return descriptor.options.find((option) => option.isDefault)?.id;
92 }
93 if (descriptor.options.some((option) => option.id === trimmed)) {
4894 return trimmed;
4995 }
50 return defaultValue ?? undefined;
96 return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id;
5197}
5298
53/** Check whether a capabilities object includes a given context window value. */
54export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean {
55 return caps.contextWindowOptions.some((o) => o.value === value);
99function 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 };
56109}
57110
58/** Return the default context window value, or `null` if none is defined. */
59export 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 */
69export 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);
111function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection {
112 return { ...selection };
76113}
77114
78export 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 } : {}),
115function 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,
89139 };
90 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
91140}
92141
93export 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;
142export 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 );
108155}
109156
110export 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;
157export 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;
125170}
126171
127function resolveLabeledOption(
128 options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined,
129 raw: string | null | undefined,
172export function getProviderOptionCurrentLabel(
173 descriptor: ProviderOptionDescriptor | null | undefined,
130174): 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;
133184 }
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;
136188 }
137 return options.find((option) => option.isDefault)?.value ?? options[0]?.value;
189 return descriptor.options.find((option) => option.id === currentValue)?.label;
138190}
139191
140export 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;
192export 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;
151209}
152210
153export 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 );
211export function getModelSelectionOptionDescriptors(
212 modelSelection: ModelSelection | null | undefined,
213 caps?: ModelCapabilities | null | undefined,
214): ReadonlyArray<ProviderOptionDescriptor> {
215 if (!modelSelection) {
216 return [];
170217 }
218 if (!caps) {
219 return [];
220 }
221 return getProviderOptionDescriptors({
222 caps,
223 selections: modelSelection.options,
224 });
171225}
172226
173227export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
......@@ -249,42 +303,51 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n
249303 return trimmed || null;
250304}
251305
306function cloneSelections(
307 selections: ReadonlyArray<ProviderOptionSelection>,
308): Array<ProviderOptionSelection> {
309 return selections.map(cloneSelection);
310}
311
252312export function createModelSelection(
253313 provider: ProviderKind,
254314 model: string,
255 options?: ProviderModelOptions[ProviderKind] | undefined,
315 options?: ReadonlyArray<ProviderOptionSelection> | null,
256316): 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 */
333export 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 }
282344 }
345 return null;
283346}
284347
285348export function applyClaudePromptEffortPrefix(
286349 text: string,
287 effort: ClaudeAgentEffort | null | undefined,
350 effort: string | null | undefined,
288351): string {
289352 const trimmed = text.trim();
290353 if (!trimmed) {
packages/shared/src/serverSettings.test.ts+40-31
......@@ -1,5 +1,6 @@
11import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
3import { createModelSelection } from "./model.ts";
34import {
45 applyServerSettingsPatch,
56 extractPersistedServerObservabilitySettings,
......@@ -56,14 +57,10 @@ describe("serverSettings helpers", () => {
5657 it("replaces text generation selection when provider/model are provided", () => {
5758 const current = {
5859 ...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 ]),
6764 };
6865
6966 expect(
......@@ -82,45 +79,35 @@ describe("serverSettings helpers", () => {
8279 it("still deep merges text generation selection when only options are provided", () => {
8380 const current = {
8481 ...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 ]),
9386 };
9487
9588 expect(
9689 applyServerSettingsPatch(current, {
9790 textGenerationModelSelection: {
98 options: {
99 fastMode: false,
100 },
91 options: [{ id: "fastMode", value: false }],
10192 },
10293 }).textGenerationModelSelection,
10394 ).toEqual({
10495 provider: "codex",
10596 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 ],
110101 });
111102 });
112103
113104 it("replaces text generation selection across providers without leaking stale options", () => {
114105 const current = {
115106 ...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 ]),
124111 };
125112
126113 expect(
......@@ -135,4 +122,26 @@ describe("serverSettings helpers", () => {
135122 model: "openai/gpt-5",
136123 });
137124 });
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 });
138147});
packages/shared/src/serverSettings.ts+27-41
......@@ -1,14 +1,8 @@
1import {
2 ServerSettings,
3 type ClaudeModelOptions,
4 type CodexModelOptions,
5 type CursorModelOptions,
6 type OpenCodeModelOptions,
7 type ServerSettingsPatch,
8} from "@t3tools/contracts";
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
92import { Schema } from "effect";
103import { deepMerge } from "./Struct.ts";
114import { fromLenientJson } from "./schemaJson.ts";
5import { createModelSelection } from "./model.ts";
126
137const ServerSettingsJson = fromLenientJson(ServerSettings);
148
......@@ -53,8 +47,23 @@ function shouldReplaceTextGenerationModelSelection(
5347 return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined));
5448}
5549
56const withModelSelectionOptions = <Options>(options: Options | undefined) =>
57 options ? { options } : {};
50function 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}
5867
5968/**
6069 * Applies a server settings patch while treating textGenerationModelSelection as
......@@ -67,44 +76,21 @@ export function applyServerSettingsPatch(
6776): ServerSettings {
6877 const selectionPatch = patch.textGenerationModelSelection;
6978 const next = deepMerge(current, patch);
70 if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) {
79 if (!selectionPatch) {
7180 return next;
7281 }
7382
7483 const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider;
7584 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 });
7691
7792 return {
7893 ...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),
10995 };
11096}