From d776a9d123ecfb2865cdfeb3caa69bfde2f2eb46 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 15:03:20 +0300 Subject: [PATCH 1/7] feat(contracts,shared): port option-array foundation (upstream #2246 commit A) Stage one of five for the upstream #2246 port. Replaces the per-provider ProviderModelOptions object (`{ effort, fastMode, ... }`) with a provider- agnostic ProviderOptionSelections array (`[{ id, value }, ...]`) on the ModelSelection schema. Adds ProviderOptionDescriptor (tagged union of select/boolean) to describe capabilities, and helpers getModelSelectionStringOptionValue / getModelSelectionBooleanOptionValue / createModelSelection / getProviderOptionDescriptors for downstream use. Preserves MarCode-specific fields: - DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.claudeAgent (Claude-first default) - claudeAgent: "claude-opus-4-6" (vs upstream "claude-sonnet-4-6") - DEFAULT_PROVIDER_KIND = "claudeAgent" - jiraBoard / additionalDirectories / compacting / CLAUDE_COMPACTING_REASON on orchestration aggregates - TurnNotificationMode / CustomNotificationSound / NotificationSoundMap + sidebarProjectGroupingMode settings additions Also lands three upstream-additive fields on ServerProvider (displayName, badgeLabel, showInteractionModeToggle) to avoid a trailing follow-up. Typecheck: @marcode/contracts + @marcode/shared pass. apps/server and apps/web will fail until commits C-E land the retrofits. Tests: 73 contracts + 117 shared passing. --- packages/contracts/src/model.ts | 180 +++++---- packages/contracts/src/orchestration.test.ts | 95 ++++- packages/contracts/src/orchestration.ts | 15 +- packages/contracts/src/provider.test.ts | 63 ++-- packages/contracts/src/server.ts | 3 + packages/contracts/src/settings.ts | 37 +- packages/shared/src/model.test.ts | 284 +++++++------- packages/shared/src/model.ts | 369 +++++++++++-------- packages/shared/src/serverSettings.test.ts | 71 ++-- packages/shared/src/serverSettings.ts | 68 ++-- 10 files changed, 656 insertions(+), 529 deletions(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5d581aa032c..8198c4fb046 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,87 +1,127 @@ -import { Schema } from "effect"; +import { Effect, Schema, SchemaTransformation } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import type { ProviderKind } from "./orchestration.ts"; -export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; -export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); -export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; -export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); -export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; -export type ClaudeCodeEffort = ClaudeAgentEffort; -export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; -export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS); -export type CursorReasoningOption = typeof CursorReasoningOption.Type; - -export type ProviderReasoningEffort = - | CodexReasoningEffort - | ClaudeAgentEffort - | CursorReasoningOption; - -export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(CodexReasoningEffort), - fastMode: Schema.optional(Schema.Boolean), -}); -export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ProviderOptionDescriptorType = Schema.Literals(["select", "boolean"]); +export type ProviderOptionDescriptorType = typeof ProviderOptionDescriptorType.Type; -export const ClaudeModelOptions = Schema.Struct({ - thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(ClaudeAgentEffort), - fastMode: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), +export const ProviderOptionChoice = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + isDefault: Schema.optional(Schema.Boolean), }); -export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export type ProviderOptionChoice = typeof ProviderOptionChoice.Type; -export const CursorModelOptions = Schema.Struct({ - reasoning: Schema.optional(CursorReasoningOption), - fastMode: Schema.optional(Schema.Boolean), - thinking: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), -}); -export type CursorModelOptions = typeof CursorModelOptions.Type; -export const OpenCodeModelOptions = Schema.Struct({ - variant: Schema.optional(TrimmedNonEmptyString), - agent: Schema.optional(TrimmedNonEmptyString), -}); -export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; +const ProviderOptionDescriptorBase = { + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), +} as const; -export const ProviderModelOptions = Schema.Struct({ - codex: Schema.optional(CodexModelOptions), - claudeAgent: Schema.optional(ClaudeModelOptions), - cursor: Schema.optional(CursorModelOptions), - opencode: Schema.optional(OpenCodeModelOptions), +export const SelectProviderOptionDescriptor = Schema.Struct({ + ...ProviderOptionDescriptorBase, + type: Schema.Literal("select"), + options: Schema.Array(ProviderOptionChoice), + currentValue: Schema.optional(TrimmedNonEmptyString), + promptInjectedValues: Schema.optional(Schema.Array(TrimmedNonEmptyString)), }); -export type ProviderModelOptions = typeof ProviderModelOptions.Type; +export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type; -export const EffortOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), +export const BooleanProviderOptionDescriptor = Schema.Struct({ + ...ProviderOptionDescriptorBase, + type: Schema.Literal("boolean"), + currentValue: Schema.optional(Schema.Boolean), }); -export type EffortOption = typeof EffortOption.Type; +export type BooleanProviderOptionDescriptor = typeof BooleanProviderOptionDescriptor.Type; -export const ContextWindowOption = Schema.Struct({ - value: TrimmedNonEmptyString, - label: TrimmedNonEmptyString, - isDefault: Schema.optional(Schema.Boolean), +export const ProviderOptionDescriptor = Schema.Union([ + SelectProviderOptionDescriptor, + BooleanProviderOptionDescriptor, +]); +export type ProviderOptionDescriptor = typeof ProviderOptionDescriptor.Type; + +export const ProviderOptionSelectionValue = Schema.Union([TrimmedNonEmptyString, Schema.Boolean]); +export type ProviderOptionSelectionValue = typeof ProviderOptionSelectionValue.Type; + +export const ProviderOptionSelection = Schema.Struct({ + id: TrimmedNonEmptyString, + value: ProviderOptionSelectionValue, }); -export type ContextWindowOption = typeof ContextWindowOption.Type; +export type ProviderOptionSelection = typeof ProviderOptionSelection.Type; + +/** + * Legacy on-disk shape for provider option selections, kept readable by the + * decoder so we can tolerate stored data written before the v3 array shape. + * + * Persisted historically as `{ effort: "max", fastMode: true, ... }` inside + * `modelSelection.options`. Migration 026 rewrites stored rows to the + * canonical array shape, but we still see the legacy form in: + * - `settings.json` files from older client builds, + * - SQLite databases that have not yet run migration 026, + * - any future regression that re-introduces the legacy shape. + */ +const LegacyProviderOptionSelectionsObject = Schema.Record(Schema.String, Schema.Unknown); + +const ProviderOptionSelectionsFromLegacyObject = LegacyProviderOptionSelectionsObject.pipe( + Schema.decodeTo( + Schema.Array(ProviderOptionSelection), + SchemaTransformation.transformOrFail({ + decode: (record) => Effect.succeed(coerceLegacyOptionsObjectToArray(record)), + encode: (selections) => Effect.succeed(canonicalSelectionsToLegacyObject(selections)), + }), + ), +); + +/** + * Schema for the `options` field of every `ModelSelection` variant. + * + * Accepts both: + * - the canonical array shape `Array<{ id, value }>` (preferred), and + * - the legacy object shape `Record` from + * pre-migration data. + * + * Always normalizes to the canonical array on decode and re-encodes as the + * canonical array, so any legacy storage gets cleaned up the next time the + * containing record is written back. + */ +export const ProviderOptionSelections = Schema.Union([ + Schema.Array(ProviderOptionSelection), + ProviderOptionSelectionsFromLegacyObject, +]); +export type ProviderOptionSelections = typeof ProviderOptionSelections.Type; + +function coerceLegacyOptionsObjectToArray( + record: Record, +): ReadonlyArray { + const entries: Array = []; + for (const [rawKey, rawValue] of Object.entries(record)) { + const id = typeof rawKey === "string" ? rawKey.trim() : ""; + if (!id) continue; + if (typeof rawValue === "string") { + const trimmed = rawValue.trim(); + if (trimmed) entries.push({ id, value: trimmed }); + } else if (typeof rawValue === "boolean") { + entries.push({ id, value: rawValue }); + } + // Drop anything else (numbers, null, nested objects/arrays) to match the + // permissive normalization performed by migration 026. + } + return entries; +} + +function canonicalSelectionsToLegacyObject( + selections: ReadonlyArray, +): Record { + const out: Record = {}; + for (const { id, value } of selections) { + out[id] = value; + } + return out; +} export const ModelCapabilities = Schema.Struct({ - reasoningEffortLevels: Schema.Array(EffortOption), - supportsFastMode: Schema.Boolean, - supportsThinkingToggle: Schema.Boolean, - contextWindowOptions: Schema.Array(ContextWindowOption), - promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), - variantOptions: Schema.optional(Schema.Array(EffortOption)), - agentOptions: Schema.optional(Schema.Array(EffortOption)), + optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }); export type ModelCapabilities = typeof ModelCapabilities.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index b7f1a7c844f..190e09aa631 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -33,6 +33,13 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); + +function getOptionValue( + options: ReadonlyArray<{ id: string; value: unknown }> | undefined, + id: string, +): unknown { + return options?.find((option) => option.id === id)?.value; +} const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); @@ -241,7 +248,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => prepareWorktree: { projectCwd: "/tmp/workspace", baseBranch: "main", - branch: "marcode/example", + branch: "t3code/example", }, runSetupScript: true, }, @@ -364,19 +371,97 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => modelSelection: { provider: "codex", model: "gpt-5.3-codex", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.modelSelection?.provider, "codex"); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "reasoningEffort"), "high"); + assert.strictEqual(getOptionValue(parsed.modelSelection?.options, "fastMode"), true); + }), +); + +it.effect("normalizes legacy object-shaped modelSelection.options on decode", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadCreatedPayload({ + threadId: "thread-1", + projectId: "project-1", + title: "Legacy options thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", options: { - reasoningEffort: "high", + effort: "max", fastMode: true, + // Falsy/garbage entries are dropped, matching migration 026. + emptyStr: " ", + nullish: null, + nested: { foo: 1 }, }, }, + branch: null, + worktreePath: null, createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.modelSelection?.provider, "codex"); - assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); - assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); + + assert.strictEqual(parsed.modelSelection.provider, "claudeAgent"); + assert.deepStrictEqual(parsed.modelSelection.options, [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]); + }), +); + +it.effect("normalizes legacy object-shaped defaultModelSelection.options on decode", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreatedPayload({ + projectId: "project-1", + title: "Legacy default project", + workspaceRoot: "/tmp/legacy", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low" }, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(parsed.defaultModelSelection?.options, [ + { id: "reasoningEffort", value: "low" }, + ]); }), ); +it.effect( + "normalizes legacy object-shaped options on decode and re-encodes as canonical array", + () => + Effect.gen(function* () { + const decoded = yield* decodeThreadCreatedPayload({ + threadId: "thread-1", + projectId: "project-1", + title: "Round trip thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { fastMode: true }, + }, + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const encoded = yield* Schema.encodeEffect(ThreadCreatedPayload)(decoded); + assert.deepStrictEqual(encoded.modelSelection.options, [{ id: "fastMode", value: true }]); + }), +); + it.effect("accepts a title seed in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9c62797c305..2ffca39c9b7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,10 +1,5 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - OpenCodeModelOptions, -} from "./model.ts"; +import { ProviderOptionSelections } from "./model.ts"; import { JiraBoardReference } from "./jira.ts"; import { RepositoryIdentity } from "./environment.ts"; import { @@ -51,27 +46,27 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "claudeAgent"; export const CodexModelSelection = Schema.Struct({ provider: Schema.Literal("codex"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(CodexModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(ClaudeModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const CursorModelSelection = Schema.Struct({ provider: Schema.Literal("cursor"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(CursorModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type CursorModelSelection = typeof CursorModelSelection.Type; export const OpenCodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, - options: Schema.optionalKey(OpenCodeModelOptions), + options: Schema.optionalKey(ProviderOptionSelections), }); export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index f91d0c89e0a..9bbc3ff10b6 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -6,6 +6,13 @@ import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts" const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); +function getOptionValue( + options: ReadonlyArray<{ id: string; value: unknown }> | undefined, + id: string, +): unknown { + return options?.find((option) => option.id === id)?.value; +} + describe("ProviderSessionStartInput", () => { it("accepts codex-compatible payloads", () => { const parsed = decodeProviderSessionStartInput({ @@ -15,10 +22,10 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, runtimeMode: "full-access", }); @@ -28,8 +35,8 @@ describe("ProviderSessionStartInput", () => { if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } - expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("high"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); it("rejects payloads without runtime mode", () => { @@ -49,11 +56,11 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - thinking: true, - effort: "max", - fastMode: true, - }, + options: [ + { id: "thinking", value: true }, + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], }, runtimeMode: "full-access", }); @@ -63,9 +70,9 @@ describe("ProviderSessionStartInput", () => { if (parsed.modelSelection?.provider !== "claudeAgent") { throw new Error("Expected claude modelSelection"); } - expect(parsed.modelSelection.options?.thinking).toBe(true); - expect(parsed.modelSelection.options?.effort).toBe("max"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "thinking")).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("max"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); expect(parsed.runtimeMode).toBe("full-access"); }); @@ -78,14 +85,14 @@ describe("ProviderSessionStartInput", () => { modelSelection: { provider: "cursor", model: "composer-2", - options: { fastMode: true }, + options: [{ id: "fastMode", value: true }], }, }); expect(parsed.provider).toBe("cursor"); expect(parsed.modelSelection?.provider).toBe("cursor"); expect(parsed.modelSelection?.model).toBe("composer-2"); if (parsed.modelSelection?.provider === "cursor") { - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); } }); }); @@ -97,10 +104,10 @@ describe("ProviderSendTurnInput", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ], }, }); @@ -109,8 +116,8 @@ describe("ProviderSendTurnInput", () => { if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } - expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "reasoningEffort")).toBe("xhigh"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); it("accepts claude modelSelection including ultrathink", () => { @@ -119,10 +126,10 @@ describe("ProviderSendTurnInput", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - fastMode: true, - }, + options: [ + { id: "effort", value: "ultrathink" }, + { id: "fastMode", value: true }, + ], }, }); @@ -130,7 +137,7 @@ describe("ProviderSendTurnInput", () => { if (parsed.modelSelection?.provider !== "claudeAgent") { throw new Error("Expected claude modelSelection"); } - expect(parsed.modelSelection.options?.effort).toBe("ultrathink"); - expect(parsed.modelSelection.options?.fastMode).toBe(true); + expect(getOptionValue(parsed.modelSelection.options, "effort")).toBe("ultrathink"); + expect(getOptionValue(parsed.modelSelection.options, "fastMode")).toBe(true); }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3cd25f2e8e9..1f048c13407 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -85,6 +85,9 @@ export type ServerProviderSkill = typeof ServerProviderSkill.Type; export const ServerProvider = Schema.Struct({ provider: ProviderKind, + displayName: Schema.optional(TrimmedNonEmptyString), + badgeLabel: Schema.optional(TrimmedNonEmptyString), + showInteractionModeToggle: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 770c110ef2d..7598e48b0e9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,11 +3,8 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - OpenCodeModelOptions, + ProviderOptionSelections, } from "./model.ts"; import { ModelSelection, ProviderKind } from "./orchestration.ts"; @@ -218,50 +215,26 @@ export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── -const CodexModelOptionsPatch = Schema.Struct({ - reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), - fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), -}); - -const ClaudeModelOptionsPatch = Schema.Struct({ - thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), - effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), - fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), - contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), -}); - -const CursorModelOptionsPatch = Schema.Struct({ - reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), - fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), - thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), - contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), -}); - -const OpenCodeModelOptionsPatch = Schema.Struct({ - variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), - agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), -}); - const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(CodexModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("claudeAgent")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(ClaudeModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("cursor")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(CursorModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("opencode")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(OpenCodeModelOptionsPatch), + options: Schema.optionalKey(ProviderOptionSelections), }), ]); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index a799d7e3957..30af2eaf5ce 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -3,46 +3,67 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@marcode/cont import { applyClaudePromptEffortPrefix, - getDefaultContextWindow, - getDefaultEffort, - hasContextWindowOption, - hasEffortLevel, + buildProviderOptionSelectionsFromDescriptors, + createModelCapabilities, + createModelSelection, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, normalizeModelSlug, - resolveContextWindow, - resolveEffort, resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, } from "./model.ts"; -const codexCaps: ModelCapabilities = { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, +const codexCaps: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "xhigh", label: "Extra High" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; - -const claudeCaps: ModelCapabilities = { - reasoningEffortLevels: [ - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, +}); + +const claudeCaps: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + currentValue: "1m", + }, ], - promptInjectedEffortLevels: ["ultrathink"], -}; +}); describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { @@ -78,7 +99,7 @@ describe("resolveSelectableModel", () => { it("resolves exact slugs, labels, and aliases", () => { const options = [ { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { slug: "claude-sonnet-4-6", name: "Sonnet 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]; expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); @@ -86,54 +107,6 @@ describe("resolveSelectableModel", () => { }); }); -describe("capability helpers", () => { - it("reads default efforts", () => { - expect(getDefaultEffort(codexCaps)).toBe("high"); - expect(getDefaultEffort(claudeCaps)).toBe("high"); - }); - - it("checks effort support", () => { - expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); - expect(hasEffortLevel(codexCaps, "max")).toBe(false); - }); -}); - -describe("resolveEffort", () => { - it("returns the explicit value when supported and not prompt-injected", () => { - expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); - expect(resolveEffort(codexCaps, "high")).toBe("high"); - expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); - }); - - it("falls back to default when value is unsupported", () => { - expect(resolveEffort(codexCaps, "bogus")).toBe("high"); - expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveEffort(codexCaps, undefined)).toBe("high"); - expect(resolveEffort(codexCaps, null)).toBe("high"); - expect(resolveEffort(codexCaps, "")).toBe("high"); - expect(resolveEffort(codexCaps, " ")).toBe("high"); - }); - - it("excludes prompt-injected efforts and falls back to default", () => { - expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); - }); - - it("returns undefined for models with no effort levels", () => { - const noCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; - expect(resolveEffort(noCaps, undefined)).toBeUndefined(); - expect(resolveEffort(noCaps, "high")).toBeUndefined(); - }); -}); - describe("misc helpers", () => { it("detects ultrathink prompts", () => { expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); @@ -156,95 +129,88 @@ describe("misc helpers", () => { }); }); -describe("context window helpers", () => { - it("reads default context window", () => { - expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); - }); - - it("returns null for models without context window options", () => { - expect(getDefaultContextWindow(codexCaps)).toBeNull(); - }); - - it("checks context window support", () => { - expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); - expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); - }); -}); - -describe("resolveContextWindow", () => { - it("returns the explicit value when supported", () => { - expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); - expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); - }); - - it("falls back to default when value is unsupported", () => { - expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); - }); - - it("returns the default when no value is provided", () => { - expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); - expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); - }); - - it("returns undefined for models with no context window options", () => { - expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); - expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); - }); -}); - -describe("normalize*ModelOptionsWithCapabilities", () => { - it("preserves explicit false codex fast mode", () => { +describe("descriptor helpers", () => { + it("applies selection values to capability descriptors", () => { expect( - normalizeCodexModelOptionsWithCapabilities(codexCaps, { - reasoningEffort: "high", - fastMode: false, + getProviderOptionDescriptors({ + caps: claudeCaps, + selections: [ + { id: "effort", value: "medium" }, + { id: "contextWindow", value: "200k" }, + ], }), - ).toEqual({ - reasoningEffort: "high", - fastMode: false, + ).toEqual([ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "medium", + promptInjectedValues: ["ultrathink"], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + currentValue: "200k", + }, + ]); + }); + + it("builds wire-format option selections from descriptors", () => { + const descriptors = getProviderOptionDescriptors({ + caps: codexCaps, + selections: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }); + + expect(buildProviderOptionSelectionsFromDescriptors(descriptors)).toEqual([ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); }); - it("preserves the default Claude context window explicitly", () => { + it("stores option selection arrays in model selections", () => { expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), + createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), ).toEqual({ - effort: "high", - contextWindow: "200k", + provider: "codex", + model: "gpt-5.4", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }); }); - it("omits unsupported Claude context window options", () => { + it("reads typed option selection values", () => { + const selection = createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); + + expect(getProviderOptionStringSelectionValue(selection.options, "reasoningEffort")).toBe( + "high", + ); + expect(getProviderOptionStringSelectionValue(selection.options, "fastMode")).toBeUndefined(); + expect(getProviderOptionBooleanSelectionValue(selection.options, "fastMode")).toBe(true); expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), - ).toEqual({ - thinking: true, - }); + getProviderOptionBooleanSelectionValue(selection.options, "reasoningEffort"), + ).toBeUndefined(); + expect(getModelSelectionStringOptionValue(selection, "reasoningEffort")).toBe("high"); + expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ac4ea1e94c1..647d2c87aef 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,15 +1,11 @@ import { DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeAgentEffort, - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, type ModelCapabilities, type ModelSelection, - type OpenCodeModelOptions, + type ProviderOptionDescriptor, + type ProviderOptionSelection, type ProviderKind, - type ProviderModelOptions, } from "@marcode/contracts"; export interface SelectableModelOption { @@ -17,157 +13,215 @@ export interface SelectableModelOption { name: string; } -/** Check whether a capabilities object includes a given effort value. */ -export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { - return caps.reasoningEffortLevels.some((l) => l.value === value); +export function createModelCapabilities(input: { + optionDescriptors: ReadonlyArray; +}): ModelCapabilities { + return { + optionDescriptors: input.optionDescriptors.map(cloneDescriptor), + }; } -/** Return the default effort value for a capabilities object, or null if none. */ -export function getDefaultEffort(caps: ModelCapabilities): string | null { - return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; +function getRawSelectionValueById( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + const selection = selections?.find((candidate) => candidate.id === id); + return selection?.value; } -/** - * Resolve a raw effort option against capabilities. - * - * Returns the explicit supported value when present and not prompt-injected, - * otherwise the model default. Returns `undefined` when the model exposes no - * effort levels. - */ -export function resolveEffort( - caps: ModelCapabilities, +export function getProviderOptionSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): string | boolean | undefined { + return getRawSelectionValueById(selections, id); +} + +export function getProviderOptionStringSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): string | undefined { + const value = getProviderOptionSelectionValue(selections, id); + return typeof value === "string" ? value : undefined; +} + +export function getProviderOptionBooleanSelectionValue( + selections: ReadonlyArray | null | undefined, + id: string, +): boolean | undefined { + const value = getProviderOptionSelectionValue(selections, id); + return typeof value === "boolean" ? value : undefined; +} + +export function getModelSelectionOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): string | boolean | undefined { + return getProviderOptionSelectionValue(modelSelection?.options, id); +} + +export function getModelSelectionStringOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): string | undefined { + return getProviderOptionStringSelectionValue(modelSelection?.options, id); +} + +export function getModelSelectionBooleanOptionValue( + modelSelection: ModelSelection | null | undefined, + id: string, +): boolean | undefined { + return getProviderOptionBooleanSelectionValue(modelSelection?.options, id); +} + +function resolveDescriptorChoiceValue( + descriptor: Extract, raw: string | null | undefined, ): string | undefined { - const defaultValue = getDefaultEffort(caps); - const trimmed = typeof raw === "string" ? raw.trim() : null; + const trimmed = trimOrNull(raw); + if (!trimmed) { + return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; + } + if (descriptor.options.length === 0) { + return trimmed; + } if ( - trimmed && - !caps.promptInjectedEffortLevels.includes(trimmed) && - hasEffortLevel(caps, trimmed) + descriptor.promptInjectedValues?.includes(trimmed) && + descriptor.options.some((option) => option.id === trimmed) ) { + return descriptor.options.find((option) => option.isDefault)?.id; + } + if (descriptor.options.some((option) => option.id === trimmed)) { return trimmed; } - return defaultValue ?? undefined; + return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id; } -/** Check whether a capabilities object includes a given context window value. */ -export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { - return caps.contextWindowOptions.some((o) => o.value === value); +function cloneDescriptor(descriptor: ProviderOptionDescriptor): ProviderOptionDescriptor { + return descriptor.type === "select" + ? { + ...descriptor, + options: [...descriptor.options], + ...(descriptor.promptInjectedValues + ? { promptInjectedValues: [...descriptor.promptInjectedValues] } + : {}), + } + : { ...descriptor }; } -/** Return the default context window value, or `null` if none is defined. */ -export function getDefaultContextWindow(caps: ModelCapabilities): string | null { - return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; -} - -/** - * Resolve a raw `contextWindow` option against capabilities. - * - * Returns the explicit supported value when present, otherwise the model - * default. Returns `undefined` when the model exposes no context window options. - */ -export function resolveContextWindow( - caps: ModelCapabilities, - raw: string | null | undefined, -): string | undefined { - const defaultValue = getDefaultContextWindow(caps); - if (!raw) return defaultValue ?? undefined; - return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); +function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection { + return { ...selection }; } -export function normalizeCodexModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastMode !== undefined ? { fastMode } : {}), +function withDescriptorCurrentValue( + descriptor: ProviderOptionDescriptor, + rawCurrentValue: string | boolean | undefined, +): ProviderOptionDescriptor { + if (descriptor.type === "boolean") { + if (typeof rawCurrentValue === "boolean") { + return { + ...descriptor, + currentValue: rawCurrentValue, + }; + } + return descriptor; + } + const currentValue = + typeof rawCurrentValue === "string" + ? resolveDescriptorChoiceValue(descriptor, rawCurrentValue) + : resolveDescriptorChoiceValue(descriptor, descriptor.currentValue); + if (!currentValue) { + const { currentValue: _unusedCurrentValue, ...rest } = descriptor; + return rest; + } + return { + ...descriptor, + currentValue, }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } -export function normalizeClaudeModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const effort = resolveEffort(caps, modelOptions?.effort); - const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: ClaudeModelOptions = { - ...(thinking !== undefined ? { thinking } : {}), - ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function getProviderOptionDescriptors(input: { + caps: ModelCapabilities; + selections?: ReadonlyArray | null | undefined; +}): ReadonlyArray { + const { caps, selections } = input; + const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); + + return baseDescriptors.map((descriptor) => + withDescriptorCurrentValue( + descriptor, + getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue, + ), + ); } -export function normalizeCursorModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CursorModelOptions | null | undefined, -): CursorModelOptions | undefined { - const reasoning = resolveEffort(caps, modelOptions?.reasoning); - const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; - const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: CursorModelOptions = { - ...(reasoning ? { reasoning: reasoning as CursorModelOptions["reasoning"] } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(thinking !== undefined ? { thinking } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function getProviderOptionCurrentValue( + descriptor: ProviderOptionDescriptor | null | undefined, +): string | boolean | undefined { + if (!descriptor) { + return undefined; + } + if (descriptor.type === "boolean") { + return descriptor.currentValue; + } + if (descriptor.currentValue) { + return descriptor.currentValue; + } + return descriptor.options.find((option) => option.isDefault)?.id; } -function resolveLabeledOption( - options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, - raw: string | null | undefined, +export function getProviderOptionCurrentLabel( + descriptor: ProviderOptionDescriptor | null | undefined, ): string | undefined { - if (!options || options.length === 0) { - return raw ?? undefined; + if (!descriptor) { + return undefined; + } + if (descriptor.type === "boolean") { + return typeof descriptor.currentValue === "boolean" + ? descriptor.currentValue + ? "On" + : "Off" + : undefined; } - if (raw && options.some((option) => option.value === raw)) { - return raw; + const currentValue = getProviderOptionCurrentValue(descriptor); + if (typeof currentValue !== "string") { + return undefined; } - return options.find((option) => option.isDefault)?.value ?? options[0]?.value; + return descriptor.options.find((option) => option.id === currentValue)?.label; } -export function normalizeOpenCodeModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: OpenCodeModelOptions | null | undefined, -): OpenCodeModelOptions | undefined { - const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); - const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); - const nextOptions: OpenCodeModelOptions = { - ...(variant ? { variant } : {}), - ...(agent ? { agent } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +export function buildProviderOptionSelectionsFromDescriptors( + descriptors: ReadonlyArray | null | undefined, +): Array | undefined { + if (!descriptors || descriptors.length === 0) { + return undefined; + } + + const nextSelections: Array = []; + + for (const descriptor of descriptors) { + const value = getProviderOptionCurrentValue(descriptor); + if (typeof value === "string" || typeof value === "boolean") { + nextSelections.push({ id: descriptor.id, value }); + } + } + + return nextSelections.length > 0 ? nextSelections : undefined; } -export function normalizeProviderModelOptionsWithCapabilities( - provider: ProviderKind, - caps: ModelCapabilities, - modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, -): ProviderModelOptions[ProviderKind] | undefined { - switch (provider) { - case "codex": - return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); - case "claudeAgent": - return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); - case "cursor": - return normalizeCursorModelOptionsWithCapabilities(caps, modelOptions as CursorModelOptions); - case "opencode": - return normalizeOpenCodeModelOptionsWithCapabilities( - caps, - modelOptions as OpenCodeModelOptions, - ); +export function getModelSelectionOptionDescriptors( + modelSelection: ModelSelection | null | undefined, + caps?: ModelCapabilities | null | undefined, +): ReadonlyArray { + if (!modelSelection) { + return []; } + if (!caps) { + return []; + } + return getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -249,42 +303,51 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +function cloneSelections( + selections: ReadonlyArray, +): Array { + return selections.map(cloneSelection); +} + export function createModelSelection( provider: ProviderKind, model: string, - options?: ProviderModelOptions[ProviderKind] | undefined, + options?: ReadonlyArray | null, ): ModelSelection { - switch (provider) { - case "codex": - return { - provider, - model, - ...(options ? { options: options as CodexModelOptions } : {}), - }; - case "claudeAgent": - return { - provider, - model, - ...(options ? { options: options as ClaudeModelOptions } : {}), - }; - case "cursor": - return { - provider, - model, - ...(options ? { options: options as CursorModelOptions } : {}), - }; - case "opencode": - return { - provider, - model, - ...(options ? { options: options as OpenCodeModelOptions } : {}), - }; + const selections = options ? cloneSelections(options) : []; + return { + provider, + model, + ...(selections.length > 0 ? { options: selections } : {}), + } as ModelSelection; +} + +/** + * Returns the effort value if it is a prompt-injected value according to + * any select descriptor in the given capabilities, or null otherwise. + * + * Unlike a single `find`, this checks every descriptor so that the + * correct descriptor's `promptInjectedValues` list is consulted even when + * multiple select descriptors exist. + */ +export function resolvePromptInjectedEffort( + caps: ModelCapabilities, + rawEffort: string | null | undefined, +): string | null { + const trimmed = trimOrNull(rawEffort); + if (!trimmed) return null; + const descriptors = getProviderOptionDescriptors({ caps }); + for (const descriptor of descriptors) { + if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) { + return trimmed; + } } + return null; } export function applyClaudePromptEffortPrefix( text: string, - effort: ClaudeAgentEffort | null | undefined, + effort: string | null | undefined, ): string { const trimmed = text.trim(); if (!trimmed) { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0e6acdc1162..b0247f10dc9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS } from "@marcode/contracts"; import { describe, expect, it } from "vitest"; +import { createModelSelection } from "./model.ts"; import { applyServerSettingsPatch, extractPersistedServerObservabilitySettings, @@ -56,14 +57,10 @@ describe("serverSettings helpers", () => { it("replaces text generation selection when provider/model are provided", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( @@ -82,45 +79,35 @@ describe("serverSettings helpers", () => { it("still deep merges text generation selection when only options are provided", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( applyServerSettingsPatch(current, { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }).textGenerationModelSelection, ).toEqual({ provider: "codex", model: "gpt-5.4-mini", - options: { - reasoningEffort: "high", - fastMode: false, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ], }); }); it("replaces text generation selection across providers without leaking stale options", () => { const current = { ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - provider: "codex" as const, - model: "gpt-5.4-mini", - options: { - reasoningEffort: "high" as const, - fastMode: true, - }, - }, + textGenerationModelSelection: createModelSelection("codex", "gpt-5.4-mini", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }; expect( @@ -135,4 +122,26 @@ describe("serverSettings helpers", () => { model: "openai/gpt-5", }); }); + + it("accepts array-based text generation selection patches", () => { + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + textGenerationModelSelection: { + provider: "opencode", + model: "openai/gpt-5", + options: [ + { id: "variant", value: "prod" }, + { id: "agent", value: "build" }, + ], + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "opencode", + model: "openai/gpt-5", + options: [ + { id: "variant", value: "prod" }, + { id: "agent", value: "build" }, + ], + }); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1e09c894066..1d911e7698c 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,14 +1,8 @@ -import { - ServerSettings, - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, - type OpenCodeModelOptions, - type ServerSettingsPatch, -} from "@marcode/contracts"; +import { ServerSettings, type ServerSettingsPatch } from "@marcode/contracts"; import { Schema } from "effect"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; +import { createModelSelection } from "./model.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -53,8 +47,23 @@ function shouldReplaceTextGenerationModelSelection( return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); } -const withModelSelectionOptions = (options: Options | undefined) => - options ? { options } : {}; +function mergeModelSelectionOptionsById(input: { + current: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; + patch: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; +}): Array<{ id: string; value: string | boolean }> | undefined { + if (input.patch === undefined) { + return input.current ? [...input.current] : undefined; + } + if (input.patch.length === 0) { + return undefined; + } + + const merged = new Map((input.current ?? []).map((selection) => [selection.id, selection.value])); + for (const selection of input.patch) { + merged.set(selection.id, selection.value); + } + return [...merged.entries()].map(([id, value]) => ({ id, value })); +} /** * Applies a server settings patch while treating textGenerationModelSelection as @@ -67,44 +76,21 @@ export function applyServerSettingsPatch( ): ServerSettings { const selectionPatch = patch.textGenerationModelSelection; const next = deepMerge(current, patch); - if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { + if (!selectionPatch) { return next; } const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; const model = selectionPatch.model ?? current.textGenerationModelSelection.model; + const options = shouldReplaceTextGenerationModelSelection(selectionPatch) + ? selectionPatch.options + : mergeModelSelectionOptionsById({ + current: current.textGenerationModelSelection.options, + patch: selectionPatch.options, + }); return { ...next, - textGenerationModelSelection: - provider === "codex" - ? { - provider, - model, - ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined), - } - : provider === "claudeAgent" - ? { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as ClaudeModelOptions | undefined, - ), - } - : provider === "cursor" - ? { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as CursorModelOptions | undefined, - ), - } - : { - provider, - model, - ...withModelSelectionOptions( - selectionPatch.options as OpenCodeModelOptions | undefined, - ), - }, + textGenerationModelSelection: createModelSelection(provider, model, options), }; } From 62fd6ea4bbc3207509202ffc06426d29a1d27b18 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 15:06:04 +0300 Subject: [PATCH 2/7] feat(server): add migration 030_CanonicalizeModelSelectionOptions (upstream #2246 commit B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports upstream's 026_CanonicalizeModelSelectionOptions, renumbered to 030 because MarCode already uses 026 for AuthSessionLastConnectedAt. MarCode's migration head was 029_CleanupInvalidProjectionPendingApprovals; this adds 030. The migration rewrites stored model-selection options from the legacy object shape (`{ effort: "max", fastMode: true }`) to the canonical array shape (`[{ id: "effort", value: "max" }, { id: "fastMode", value: true }]`) in: - projection_threads.model_selection_json.$.options - projection_projects.default_model_selection_json.$.options - orchestration_events payload for thread.created, thread.meta-updated, thread.turn-start-requested, project.created, project.meta-updated effect_sql_migrations row uses (id=30, name="CanonicalizeModelSelectionOptions") — fresh row, no conflict with existing installs. Test uses MarCode-aware bounds (seeds at migration 29, asserts after 30) and covers legacy object, empty object, non-scalar entry drop, already-array (no-op), null selection, and the five relevant event types. 5/5 migration tests pass. --- apps/server/src/persistence/Migrations.ts | 2 + ..._CanonicalizeModelSelectionOptions.test.ts | 450 ++++++++++++++++++ .../030_CanonicalizeModelSelectionOptions.ts | 138 ++++++ 3 files changed, 590 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.test.ts create mode 100644 apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d6b974d3011..e27d9fe3e64 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -41,6 +41,7 @@ import Migration0026 from "./Migrations/026_AuthSessionLastConnectedAt.ts"; import Migration0027 from "./Migrations/027_ProjectionThreadShellSummary.ts"; import Migration0028 from "./Migrations/028_BackfillProjectionThreadShellSummary.ts"; import Migration0029 from "./Migrations/029_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0030 from "./Migrations/030_CanonicalizeModelSelectionOptions.ts"; /** * Migration loader with all migrations defined inline. @@ -81,6 +82,7 @@ export const migrationEntries = [ [27, "ProjectionThreadShellSummary", Migration0027], [28, "BackfillProjectionThreadShellSummary", Migration0028], [29, "CleanupInvalidProjectionPendingApprovals", Migration0029], + [30, "CanonicalizeModelSelectionOptions", Migration0030], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.test.ts new file mode 100644 index 00000000000..cdc5c41b843 --- /dev/null +++ b/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.test.ts @@ -0,0 +1,450 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("030_CanonicalizeModelSelectionOptions", (it) => { + it.effect("converts legacy object-shape options into array-shape on projections and events", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 29 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + 'project-legacy', + 'Legacy options project', + '/tmp/legacy', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-no-options', + 'No options project', + '/tmp/no-options', + '{"provider":"codex","model":"gpt-5.4"}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-null-selection', + 'Null model selection project', + '/tmp/null-selection', + NULL, + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-already-array', + 'Already-canonical options project', + '/tmp/already-array', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at, + runtime_mode, + interaction_mode + ) + VALUES + ( + 'thread-legacy', + 'project-legacy', + 'Legacy thread', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-empty-options', + 'project-legacy', + 'Empty options thread', + '{"provider":"codex","model":"gpt-5.4","options":{}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-drop-garbage', + 'project-legacy', + 'Thread with non-scalar entries', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-no-options', + 'project-legacy', + 'No options thread', + '{"provider":"codex","model":"gpt-5.4"}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-already-array', + 'project-legacy', + 'Already array thread', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-project-created', + 'project', + 'project-legacy', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'cmd-pc', + NULL, + 'corr-pc', + 'user', + '{"projectId":"project-legacy","title":"Project","workspaceRoot":"/tmp/legacy","defaultModelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-meta-updated', + 'project', + 'project-legacy', + 2, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-pmu', + NULL, + 'corr-pmu', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-null-selection', + 'project', + 'project-legacy', + 3, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-null', + NULL, + 'corr-null', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-created', + 'thread', + 'thread-legacy', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-tc', + NULL, + 'corr-tc', + 'user', + '{"threadId":"thread-legacy","projectId":"project-legacy","title":"Thread","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-meta-updated', + 'thread', + 'thread-legacy', + 2, + 'thread.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-tmu', + NULL, + 'corr-tmu', + 'user', + '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-turn-start', + 'thread', + 'thread-legacy', + 3, + 'thread.turn-start-requested', + '2026-01-01T00:00:00.000Z', + 'cmd-tts', + NULL, + 'corr-tts', + 'user', + '{"threadId":"thread-legacy","messageId":"msg-1","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","contextWindow":"1m"}},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-already-array', + 'thread', + 'thread-legacy', + 4, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-taa', + NULL, + 'corr-taa', + 'user', + '{"threadId":"thread-already-array","projectId":"project-legacy","title":"Already Array","modelSelection":{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"medium"}]},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-activity-append', + 'thread', + 'thread-legacy', + 5, + 'thread.activity-appended', + '2026-01-01T00:00:00.000Z', + 'cmd-aa', + NULL, + 'corr-aa', + 'user', + '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}', + '{}' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 30 }); + + // Projection projects + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual( + projectRows.map((row) => ({ + projectId: row.projectId, + selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, + })), + [ + { + projectId: "project-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }, + { + projectId: "project-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }, + }, + { + projectId: "project-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + { projectId: "project-null-selection", selection: null }, + ], + ); + + // Projection threads + const threadRows = yield* sql<{ + readonly threadId: string; + readonly modelSelection: string | null; + }>` + SELECT + thread_id AS "threadId", + model_selection_json AS "modelSelection" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual( + threadRows.map((row) => ({ + threadId: row.threadId, + selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, + })), + [ + { + threadId: "thread-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }, + }, + { + threadId: "thread-drop-garbage", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + // Only the scalar string survives; nested object, whitespace + // string, and null are dropped. + options: [{ id: "effort", value: "high" }], + }, + }, + { + threadId: "thread-empty-options", + selection: { provider: "codex", model: "gpt-5.4", options: [] }, + }, + { + threadId: "thread-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], + }, + }, + { + threadId: "thread-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + ], + ); + + // Orchestration events + const eventRows = yield* sql<{ + readonly eventId: string; + readonly payloadJson: string; + }>` + SELECT event_id AS "eventId", payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY event_id + `; + + const payloads = Object.fromEntries( + eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), + ); + + assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); + + assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }); + + assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); + + assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + ], + }); + + assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }); + + assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }); + + // Already-array records are left untouched. + assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + }); + + // Events with no modelSelection at all are untouched. + assert.isUndefined(payloads["event-activity-append"].modelSelection); + assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.ts new file mode 100644 index 00000000000..15c08debf64 --- /dev/null +++ b/apps/server/src/persistence/Migrations/030_CanonicalizeModelSelectionOptions.ts @@ -0,0 +1,138 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from + * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the + * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`). + * + * Migration 016 introduced `modelSelection` with `options` stored as a + * per-provider object. Later the schema was reshaped so that options are a + * generic `Array<{ id, value }>` of user-selected option entries. Stored rows + * from before the reshape still have the object shape and fail to decode. + * + * For each value in the legacy object: + * - string values are kept if non-empty after trim + * - boolean values are always kept (true | false) + * - any other value type (number, null, nested object/array) is dropped, + * matching the permissive client-side normalizer in composerDraftStore. + * + * Touched storage: + * - `projection_threads.model_selection_json.options` + * - `projection_projects.default_model_selection_json.options` + * - `orchestration_events.payload_json.$.modelSelection.options` + * (thread.created | thread.meta-updated | thread.turn-start-requested) + * - `orchestration_events.payload_json.$.defaultModelSelection.options` + * (project.created | project.meta-updated) + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET model_selection_json = json_set( + model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE model_selection_json IS NOT NULL + AND json_type(model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = json_set( + default_model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(default_model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE default_model_selection_json IS NOT NULL + AND json_type(default_model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.modelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.modelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ( + 'thread.created', + 'thread.meta-updated', + 'thread.turn-start-requested' + ) + AND json_type(payload_json, '$.modelSelection.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.defaultModelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection.options') = 'object' + `; +}); From b7f903e7ec875719717023308a1180e1ff6d6597 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 15:26:38 +0300 Subject: [PATCH 3/7] feat(server): retrofit provider adapters with option-array helpers (upstream #2246 commit C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage three of five. Replaces provider-specific option access (modelSelection.options.effort / .fastMode / .thinking / .agent / .variant) with the provider-agnostic helpers introduced in Commit A: getModelSelectionStringOptionValue / getModelSelectionBooleanOptionValue / getProviderOptionDescriptors / resolvePromptInjectedEffort. Adds a new `builtInProviderCatalog.ts` module (upstream-introduced) with MarCode-order `BUILT_IN_PROVIDER_ORDER` starting with claudeAgent (Claude-first branding) rather than upstream's codex-first order. ClaudeProvider exports new helpers (resolveClaudeEffort, normalizeClaudeCliEffort) used by ClaudeAdapter for effort normalization. Each built-in model's capabilities now use createModelCapabilities + buildSelectOptionDescriptor / buildBooleanOptionDescriptor. MarCode-preferred defaults retained: Opus 4.6 and Sonnet 4.6 default to "medium" effort (vs upstream's "high"); model names use bare slugs ("Opus 4.6" vs upstream "Claude Opus 4.6"); "Claude/Cursor/Codex/OpenCode is disabled in MarCode settings" branding kept. Preserves: - MarCode's CursorAdapter session/request_permission tool-call hint logic (terminal command display) and ACP toolCallHints map — only retrofitting the modelOptions -> selections parameter name change - MarCode's OpenCodeAdapter tool-activity classification refactor via @marcode/shared/toolActivity (classifyToolLifecycleItemType, extractPlanStepsFromTodos, isTodoWriteTool) — only retrofitting options.agent / options.variant access - MarCode's ClaudeAdapter progressive error classes (ClaudeStreamError variants), interrupt handling, and todo plan-step extraction — retrofitted effort / fastMode / thinking reads via the new helpers Test adaptation: - AnalyticsService.layerTest -> AnalyticsServiceNoopLive (FEATURES.md PostHog-free requirement, pattern from PR #66's resume-drift fix) - Provider test literals migrated to array-of-{id, value} shape via createModelSelection builder - Sonnet 4.6 "fallback to default" test now asserts "medium" (MarCode's isDefault choice) rather than upstream's "high" - Grep tool classification asserted as "file_read" (via MarCode's local classifyToolItemType) rather than upstream's generic "dynamic_tool_call" - Marcode metric prefix applied (marcode_provider_* vs t3_provider_*) Tests: 244 provider + telemetry tests passing; apps/server provider layer typecheck is clean. Git text-generation and orchestration test breaks are expected and will be resolved by Commit D. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 525 ++---------------- .../src/provider/Layers/ClaudeAdapter.ts | 64 ++- .../src/provider/Layers/ClaudeProvider.ts | 234 +++++--- .../src/provider/Layers/CodexAdapter.test.ts | 23 +- .../src/provider/Layers/CodexAdapter.ts | 27 +- .../src/provider/Layers/CodexProvider.ts | 65 ++- .../src/provider/Layers/CursorAdapter.test.ts | 31 +- .../src/provider/Layers/CursorAdapter.ts | 6 +- .../provider/Layers/CursorProvider.test.ts | 195 ++++--- .../src/provider/Layers/CursorProvider.ts | 144 +++-- .../src/provider/Layers/OpenCodeAdapter.ts | 5 +- .../provider/Layers/OpenCodeProvider.test.ts | 14 +- .../src/provider/Layers/OpenCodeProvider.ts | 71 ++- .../Layers/ProviderAdapterRegistry.ts | 13 +- .../provider/Layers/ProviderRegistry.test.ts | 95 ++-- .../src/provider/Layers/ProviderRegistry.ts | 47 +- .../provider/Layers/ProviderService.test.ts | 8 +- .../src/provider/acp/CursorAcpSupport.test.ts | 10 +- .../src/provider/acp/CursorAcpSupport.ts | 6 +- .../src/provider/builtInProviderCatalog.ts | 49 ++ .../makeManagedServerProvider.test.ts | 28 +- apps/server/src/provider/opencodeRuntime.ts | 3 +- .../src/provider/providerSnapshot.test.ts | 28 +- apps/server/src/provider/providerSnapshot.ts | 55 ++ .../src/provider/providerStatusCache.test.ts | 27 +- apps/server/src/serverSettings.test.ts | 119 ++-- 26 files changed, 894 insertions(+), 998 deletions(-) create mode 100644 apps/server/src/provider/builtInProviderCatalog.ts diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 06524176b08..3ea40927d9b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -17,6 +17,7 @@ import { type RuntimeMode, ThreadId, } from "@marcode/contracts"; +import { createModelSelection } from "@marcode/shared/model"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Layer, Random, Stream } from "effect"; @@ -239,8 +240,6 @@ async function readFirstPromptMessage( const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); -const INTERRUPTED_TOOL_RESULT_TEXT = - "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."; describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -335,13 +334,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), runtimeMode: "full-access", }); @@ -353,7 +348,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => { + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -382,13 +377,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-7", - options: { - effort: "xhigh", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-7", [ + { id: "effort", value: "xhigh" }, + ]), runtimeMode: "full-access", }); @@ -407,13 +398,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "max" }, + ]), runtimeMode: "full-access", }); @@ -432,13 +419,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "high", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "effort", value: "high" }, + ]), runtimeMode: "full-access", }); @@ -457,13 +440,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-haiku-4-5", [ + { id: "thinking", value: false }, + ]), runtimeMode: "full-access", }); @@ -484,13 +463,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - thinking: false, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "thinking", value: false }, + ]), runtimeMode: "full-access", }); @@ -509,13 +484,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -536,13 +507,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -561,13 +528,9 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "ultrathink" }, + ]), runtimeMode: "full-access", }); @@ -575,13 +538,9 @@ describe("ClaudeAdapterLive", () => { threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "ultrathink" }, + ]), }); const createInput = harness.getLastCreateQueryInput(); @@ -1241,182 +1200,6 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("does not surface ede_diagnostic-only Claude results as runtime errors", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - harness.query.emit({ - type: "result", - subtype: "error_during_execution", - is_error: false, - errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], - stop_reason: "tool_use", - session_id: "sdk-session-ede-diagnostic", - uuid: "result-ede-diagnostic", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "thread.started", - "turn.completed", - ], - ); - - const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "completed"); - assert.isUndefined(turnCompleted.payload.errorMessage); - assert.equal(turnCompleted.payload.stopReason, "tool_use"); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect( - "marks rejected tool results after interruptTurn as declined and completes interrupted", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-interrupted-tool-result", - uuid: "stream-tool-start-interrupted", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "tool_use", - id: "tool-bash-1", - name: "Bash", - input: { - command: "ls", - }, - }, - }, - } as unknown as SDKMessage); - - yield* adapter.interruptTurn(session.threadId, turn.turnId); - - harness.query.emit({ - type: "user", - session_id: "sdk-session-interrupted-tool-result", - uuid: "user-tool-result-interrupted", - parent_tool_use_id: null, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-bash-1", - content: INTERRUPTED_TOOL_RESULT_TEXT, - is_error: true, - }, - ], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "error_during_execution", - is_error: true, - errors: [INTERRUPTED_TOOL_RESULT_TEXT], - stop_reason: "tool_use", - session_id: "sdk-session-interrupted-tool-result", - uuid: "result-interrupted-tool-result", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "thread.started", - "item.started", - "item.updated", - "content.delta", - "item.completed", - "turn.completed", - ], - ); - - const toolUpdated = runtimeEvents[6]; - assert.equal(toolUpdated?.type, "item.updated"); - if (toolUpdated?.type === "item.updated") { - assert.equal(toolUpdated.payload.status, "declined"); - } - - const toolCompleted = runtimeEvents[8]; - assert.equal(toolCompleted?.type, "item.completed"); - if (toolCompleted?.type === "item.completed") { - assert.equal(toolCompleted.payload.status, "declined"); - } - - const turnCompleted = runtimeEvents[9]; - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "interrupted"); - assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted."); - assert.equal(turnCompleted.payload.stopReason, "tool_use"); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); - it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1485,144 +1268,6 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect( - "treats Claude ede_diagnostic tool_use cancellation as interrupted without a runtime error", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); - - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: THREAD_ID, - input: "hello", - attachments: [], - }); - - harness.query.fail( - new Error("[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"), - ); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - runtimeEventsFiber.interruptUnsafe(); - - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "turn.completed", - "session.exited", - ], - ); - - const turnCompleted = runtimeEvents[4]; - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "interrupted"); - assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted."); - } - - const sessionExited = runtimeEvents[5]; - assert.equal(sessionExited?.type, "session.exited"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); - - it.effect( - "treats aborted Claude stream failures after interruptTurn as interrupted without a runtime error", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); - - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: THREAD_ID, - input: "hello", - attachments: [], - }); - - yield* adapter.interruptTurn(THREAD_ID, turn.turnId); - harness.query.fail( - "Error: Request was aborted.\n at makeRequest (/$bunfs/root/src/entrypoints/cli.js:50:3448)\n at processTicksAndRejections (native:7:39)", - ); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - runtimeEventsFiber.interruptUnsafe(); - - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "turn.completed", - "session.exited", - ], - ); - - const turnCompleted = runtimeEvents[4]; - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "interrupted"); - assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted."); - } - - const sessionExited = runtimeEvents[5]; - assert.equal(sessionExited?.type, "session.exited"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); it.effect("closes the previous session before replacing an existing thread session", () => { const queries: FakeClaudeQuery[] = []; const layer = makeClaudeAdapterLive({ @@ -1747,9 +1392,11 @@ describe("ClaudeAdapterLive", () => { runtimeEventsFiber.interruptUnsafe(); - assert.isUndefined( + assert.equal( promptConsumerError, - "Prompt consumer should not receive a thrown error on session stop", + undefined, + `Prompt consumer should not receive a thrown error on session stop, ` + + `but got: "${promptConsumerError instanceof Error ? promptConsumerError.message : String(promptConsumerError)}"`, ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -2914,96 +2561,6 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("preserves durable resume ids across Claude resume hooks", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const durableSessionId = "550e8400-e29b-41d4-a716-446655440000"; - const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719"; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - threadId: RESUME_THREAD_ID, - provider: "claudeAgent", - resumeCursor: { - threadId: RESUME_THREAD_ID, - resume: durableSessionId, - resumeSessionAt: "assistant-99", - turnCount: 3, - }, - runtimeMode: "full-access", - }); - - harness.query.emit({ - type: "system", - subtype: "hook_started", - hook_id: "resume-hook-1", - hook_name: "SessionStart:resume", - hook_event: "SessionStart", - session_id: transientHookSessionId, - uuid: "resume-hook-started", - } as unknown as SDKMessage); - - harness.query.emit({ - type: "system", - subtype: "hook_response", - hook_id: "resume-hook-1", - hook_name: "SessionStart:resume", - hook_event: "SessionStart", - output: "", - stdout: "", - stderr: "", - outcome: "success", - session_id: transientHookSessionId, - uuid: "resume-hook-response", - } as unknown as SDKMessage); - - harness.query.emit({ - type: "system", - subtype: "init", - apiKeySource: "none", - claude_code_version: "test", - cwd: "/tmp/claude-adapter-test", - tools: [], - mcp_servers: [], - model: "claude-sonnet-4-5", - permissionMode: "bypassPermissions", - slash_commands: [], - output_style: "default", - skills: [], - plugins: [], - session_id: durableSessionId, - uuid: "resume-init", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started"); - assert.equal(threadStartedEvents.length, 1); - const threadStarted = threadStartedEvents[0]; - assert.equal(threadStarted?.type, "thread.started"); - if (threadStarted?.type === "thread.started") { - assert.deepEqual(threadStarted.payload, { - providerThreadId: durableSessionId, - }); - } - - const activeSessions = yield* adapter.listSessions(); - const resumeCursor = activeSessions[0]?.resumeCursor as - | { - readonly resume?: string; - } - | undefined; - assert.equal(resumeCursor?.resume, durableSessionId); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - it.effect("uses an app-generated Claude session id for fresh sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -3198,13 +2755,9 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - contextWindow: "1m", - }, - }, + modelSelection: createModelSelection("claudeAgent", "claude-opus-4-6", [ + { id: "contextWindow", value: "1m" }, + ]), attachments: [], }); yield* adapter.sendTurn({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index ebbd29581d4..5709045b7b3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -42,9 +42,14 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeAgentEffort, } from "@marcode/contracts"; -import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@marcode/shared/model"; +import { + applyClaudePromptEffortPrefix, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + resolvePromptInjectedEffort, +} from "@marcode/shared/model"; import { extractPlanStepsFromTodos, isTodoWriteTool } from "@marcode/shared/toolActivity"; import { Cause, @@ -66,7 +71,12 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -246,19 +256,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeAgentEffort( - effort: ClaudeAgentEffort | null | undefined, -): ClaudeSdkEffort | null { - if (!effort) { - return null; - } - if (effort === "ultrathink") { - return null; - } - if (effort === "xhigh") { - return "max"; - } - return effort; +function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { + const normalized = normalizeClaudeCliEffort(effort); + return normalized ? (normalized as ClaudeSdkEffort) : null; } function isClaudeInterruptedMessage(message: string): boolean { @@ -669,16 +669,14 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + input.modelSelection?.provider === "claudeAgent" + ? getModelSelectionStringOptionValue(input.modelSelection, "effort") + : null; const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); - // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. - const trimmedEffort = trimOrNull(rawEffort); - const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -3047,14 +3045,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); + const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeAgentEffort | null; - const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; - const thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking - : undefined; + const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); + const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const fastModeSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + const thinkingSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); + const fastMode = + getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && + fastModeSupported; + const thinking = thinkingSupported + ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") + : undefined; const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index bed8da73351..415c32c7f6a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -11,6 +11,12 @@ import type { import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@marcode/shared/schemaJson"; +import { + createModelCapabilities, + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@marcode/shared/model"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, @@ -18,6 +24,8 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, AUTH_PROBE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, @@ -35,108 +43,143 @@ import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@marcode/contracts"; -const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); const PROVIDER = "claudeAgent" as const; +const CLAUDE_PRESENTATION = { + displayName: "Claude", + showInteractionModeToggle: true, +} as const; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-7", name: "Opus 4.7", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-opus-4-5", name: "Opus 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + }), }, { slug: "claude-sonnet-4-6", name: "Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-haiku-4-5", name: "Haiku 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildBooleanOptionDescriptor({ + id: "thinking", + label: "Thinking", + }), + ], + }), }, ]; @@ -166,8 +209,41 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } +export function resolveClaudeEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); + const value = getProviderOptionCurrentValue(effortDescriptor); + return typeof value === "string" ? value : undefined; +} + +/** + * Normalize a resolved Claude effort value into one suitable for the Claude + * CLI's `--effort` flag. + * + * Mirrors the mapping used when invoking the Claude Agent SDK + * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): the Opus 4.7 + * capability `"xhigh"` is rewritten to the accepted CLI value `"max"`, and + * `"ultrathink"` is filtered out because it is a prompt-prefix mode rather + * than a CLI-effort value. Returns `undefined` when no flag should be passed. + */ +export function normalizeClaudeCliEffort(effort: string | null | undefined): string | undefined { + if (!effort || effort === "ultrathink") { + return undefined; + } + if (effort === "xhigh") { + return "max"; + } + return effort; +} + export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (modelSelection.options?.contextWindow) { + switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { case "1m": return `${modelSelection.model}[1m]`; default: @@ -579,6 +655,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models: allModels, @@ -601,6 +678,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = versionProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -611,7 +689,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error.message}.`, + : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }, }); } @@ -619,6 +697,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(versionProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -639,6 +718,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const detail = detailFromResult(version); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models: allModels, @@ -703,6 +783,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const error = authProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -712,7 +793,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: `Could not verify Claude authentication status: ${error.message}.`, + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", }, }); } @@ -720,6 +804,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(authProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -738,6 +823,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -771,6 +857,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid if (!claudeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, models, @@ -786,6 +873,7 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid return buildServerProvider({ provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: true, checkedAt, models, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 1a73d9730a8..21a9e07de05 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -14,6 +14,7 @@ import { ThreadId, TurnId, } from "@marcode/contracts"; +import { createModelSelection } from "@marcode/shared/model"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; @@ -243,13 +244,9 @@ validationLayer("CodexAdapterLive validation", (it) => { yield* adapter.startSession({ provider: "codex", threadId: asThreadId("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); @@ -310,14 +307,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection("codex", "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), attachments: [], }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 74cd1b4ca7f..1939d211085 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,6 +26,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@marcode/shared/model"; + import { ProviderAdapterRequestError, ProviderAdapterProcessError, @@ -1372,7 +1377,8 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode + ...(input.modelSelection?.provider === "codex" && + getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true ? { serviceTier: "fast" } : {}), }; @@ -1485,19 +1491,26 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const session = yield* requireSession(input.threadId); + const reasoningEffort = + input.modelSelection?.provider === "codex" + ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") + : undefined; + const fastMode = + input.modelSelection?.provider === "codex" + ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") + : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), - ...(input.modelSelection?.provider === "codex" && - input.modelSelection.options?.reasoningEffort !== undefined - ? { effort: input.modelSelection.options.reasoningEffort } - : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode - ? { serviceTier: "fast" } + ...(reasoningEffort + ? { + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } : {}), + ...(fastMode === true ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index ec27fad4ee0..415a460c456 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -25,6 +25,8 @@ import type { } from "@marcode/contracts"; import { ServerSettingsError } from "@marcode/contracts"; +import { createModelCapabilities } from "@marcode/shared/model"; + import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { buildServerProvider } from "../providerSnapshot.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; @@ -34,6 +36,10 @@ import packageJson from "../../../package.json" with { type: "json" }; const PROVIDER = "codex" as const; const PROVIDER_PROBE_TIMEOUT_MS = 8_000; +const CODEX_PRESENTATION = { + displayName: "Codex", + showInteractionModeToggle: true, +} as const; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; @@ -87,17 +93,44 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { - return { - reasoningEffortLevels: model.supportedReasoningEfforts.map(({ reasoningEffort }) => ({ - value: reasoningEffort, - label: REASONING_EFFORT_LABELS[reasoningEffort], - ...(reasoningEffort === model.defaultReasoningEffort ? { isDefault: true } : {}), - })), - supportsFastMode: (model.additionalSpeedTiers ?? []).includes("fast"), - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; + const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => + reasoningEffort === model.defaultReasoningEffort + ? { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + isDefault: true, + } + : { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + }, + ); + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const supportsFastMode = (model.additionalSpeedTiers ?? []).includes("fast"); + return createModelCapabilities({ + optionDescriptors: [ + ...(reasoningOptions.length > 0 + ? [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }, + ] + : []), + ...(supportsFastMode + ? [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean" as const, + }, + ] + : []), + ], + }); } const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { @@ -202,7 +235,7 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { - name: "marcode_desktop", + name: "t3code_desktop", title: "MarCode Desktop", version: packageJson.version, }, @@ -232,7 +265,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const initialize = yield* client.request("initialize", { clientInfo: { - name: "marcode_desktop", + name: "t3code_desktop", title: "MarCode Desktop", version: "0.1.0", }, @@ -292,6 +325,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models, @@ -308,6 +342,7 @@ const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: true, checkedAt, models, @@ -375,6 +410,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (!codexSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models: emptyModels, @@ -401,6 +437,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const installed = !Schema.is(CodexErrors.CodexAppServerSpawnError)(error); return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, @@ -420,6 +457,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Option.isNone(probeResult.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, @@ -439,6 +477,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu return buildServerProvider({ provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: snapshot.models, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 9a34ea1e58c..16d3b9875b8 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; +import { createModelSelection } from "@marcode/shared/model"; import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@marcode/contracts"; @@ -95,7 +96,7 @@ const cursorAdapterTestLayer = it.layer( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "marcode-cursor-adapter-test-", + prefix: "t3code-cursor-adapter-test-", }), ), Layer.provideMerge(NodeServices.layer), @@ -357,15 +358,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { providers: { cursor: { binaryPath: wrapperPath } }, }); - const modelSelection = { - provider: "cursor" as const, - model: "gpt-5.4", - options: { - reasoning: "xhigh" as const, - contextWindow: "1m", - fastMode: true, - }, - }; + const modelSelection = createModelSelection("cursor", "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ]); yield* adapter.startSession({ threadId, @@ -567,7 +564,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { - prefix: "marcode-cursor-adapter-test-", + prefix: "t3code-cursor-adapter-test-", }), ), Layer.provideMerge(NodeServices.layer), @@ -1092,7 +1089,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "second turn after switching model", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: true }, + ]), }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); @@ -1147,14 +1146,18 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "first turn with fast mode", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: true }, + ]), }); yield* adapter.sendTurn({ threadId, input: "second turn without fast mode", attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, + modelSelection: createModelSelection("cursor", "composer-2", [ + { id: "fastMode", value: false }, + ]), }); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index f150d2d3ca7..086ba636043 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,7 +7,7 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, - type CursorModelOptions, + type ProviderOptionSelection, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, @@ -230,7 +230,7 @@ function applyRequestedSessionConfiguration(input: { readonly modelSelection: | { readonly model: string; - readonly options?: CursorModelOptions | null | undefined; + readonly options?: ReadonlyArray | null | undefined; } | undefined; readonly mapError: (context: { @@ -243,7 +243,7 @@ function applyRequestedSessionConfiguration(input: { yield* applyCursorAcpModelSelection({ runtime: input.runtime, model: input.modelSelection.model, - modelOptions: input.modelSelection.options, + selections: input.modelSelection.options, mapError: ({ cause }) => input.mapError({ cause, diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 4d113e490e9..6a567da9349 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -8,6 +8,7 @@ import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings, ServerProviderModel } from "@marcode/contracts"; +import { createModelCapabilities } from "@marcode/shared/model"; import { buildCursorProviderSnapshot, @@ -27,6 +28,31 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + async function makeMockAgentWrapper(extraEnv?: Record) { const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); const wrapperPath = path.join(dir, "fake-agent.sh"); @@ -239,13 +265,7 @@ const baseCursorSettings: CursorSettings = { customModels: [], }; -const emptyCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} as const; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -309,52 +329,57 @@ describe("buildCursorProviderSnapshot", () => { describe("buildCursorCapabilitiesFromConfigOptions", () => { it("derives model capabilities from parameterized Cursor ACP config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272K", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "272k", label: "272K", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + booleanDescriptor("fastMode", "Fast", false), + ], + }), + ); }); it("detects boolean thinking toggles from model_config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); it("prefers the newer model_option effort control over legacy thought_level", () => { expect( buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), - ).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "max", label: "Max", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Effort", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", true), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); }); }); @@ -365,73 +390,39 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { slug: "default", name: "Auto", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), }, { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-sonnet-4-6", name: "Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, { slug: "gpt-5.3-codex-spark", name: "Codex 5.3 Spark", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ]); }); @@ -645,11 +636,11 @@ describe("resolveCursorAcpBaseModelId", () => { describe("resolveCursorAcpConfigUpdates", () => { it("maps Cursor model options onto separate ACP config option updates", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), ).toEqual([ { configId: "reasoning", value: "extra-high" }, { configId: "context", value: "1m" }, @@ -659,26 +650,26 @@ describe("resolveCursorAcpConfigUpdates", () => { it("maps boolean thinking toggles when the model exposes them separately", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [ + { id: "thinking", value: false }, + ]), ).toEqual([{ configId: "thinking", value: false }]); }); it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - fastMode: false, - }), + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "fastMode", value: false }, + ]), ).toEqual([{ configId: "fast", value: "false" }]); }); it("writes Cursor effort changes through the newer model_option config when available", () => { expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { - reasoning: "max", - thinking: false, - }), + resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, [ + { id: "reasoning", value: "max" }, + { id: "thinking", value: false }, + ]), ).toEqual([ { configId: "effort", value: "max" }, { configId: "thinking", value: "false" }, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 88d6b380930..037df03c3b7 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -3,9 +3,9 @@ import * as nodeOs from "node:os"; import * as nodePath from "node:path"; import type { - CursorModelOptions, CursorSettings, ModelCapabilities, + ProviderOptionSelection, ServerProvider, ServerProviderAuth, ServerProviderModel, @@ -15,8 +15,15 @@ import type { import type * as EffectAcpSchema from "effect-acp/schema"; import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + createModelCapabilities, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@marcode/shared/model"; import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, collectStreamAsString, isCommandMissingCause, @@ -29,13 +36,14 @@ import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const PROVIDER = "cursor" as const; -const EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const CURSOR_PRESENTATION = { + displayName: "Cursor", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; @@ -55,6 +63,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models, @@ -70,6 +79,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: true, checkedAt, models, @@ -198,6 +208,28 @@ function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): return values.has("true") && values.has("false"); } +function getBooleanCurrentValue( + option: EffectAcpSchema.SessionConfigOption | undefined, +): boolean | undefined { + if (!option) { + return undefined; + } + if (option.type === "boolean") { + return option.currentValue; + } + if (option.type !== "select") { + return undefined; + } + const normalized = option.currentValue?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return undefined; +} + export function buildCursorCapabilitiesFromConfigOptions( configOptions: ReadonlyArray | null | undefined, ): ModelCapabilities { @@ -251,14 +283,60 @@ export function buildCursorCapabilitiesFromConfigOptions( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); + const fastCurrentValue = getBooleanCurrentValue(fastOption); + const thinkingCurrentValue = getBooleanCurrentValue(thinkingOption); + const optionDescriptors = [ + ...(reasoningEffortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []), + ...(contextWindowOptions.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: contextOption?.name?.trim() || "Context Window", + options: contextWindowOptions, + }), + ] + : []), + ...(fastOption && isBooleanLikeConfigOption(fastOption) + ? [ + typeof fastCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + currentValue: fastCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + }), + ] + : []), + ...(thinkingOption && isBooleanLikeConfigOption(thinkingOption) + ? [ + typeof thinkingCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + currentValue: thinkingCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + }), + ] + : []), + ]; - return { - reasoningEffortLevels, - supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, - supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, - contextWindowOptions, - promptInjectedEffortLevels: [], - }; + return createModelCapabilities({ + optionDescriptors, + }); } function buildCursorDiscoveredModels( @@ -282,13 +360,7 @@ function buildCursorDiscoveredModels( } function hasCursorModelCapabilities(model: Pick): boolean { - return ( - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 - ); + return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; } export function buildCursorDiscoveredModelsFromConfigOptions( @@ -387,7 +459,7 @@ export function resolveCursorAcpBaseModelId(model: string | null | undefined): s export function resolveCursorAcpConfigUpdates( configOptions: ReadonlyArray | null | undefined, - modelOptions: CursorModelOptions | null | undefined, + selections: ReadonlyArray | null | undefined, ): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { if (!configOptions || configOptions.length === 0) { return []; @@ -396,7 +468,9 @@ export function resolveCursorAcpConfigUpdates( const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; const reasoningOption = findCursorEffortConfigOption(configOptions); - const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); + const requestedReasoning = normalizeCursorReasoningValue( + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { const normalizedValue = normalizeCursorReasoningValue(option.value); @@ -411,14 +485,15 @@ export function resolveCursorAcpConfigUpdates( const contextOption = configOptions.find( (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - if (contextOption && modelOptions?.contextWindow) { + const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); + if (contextOption && requestedContextWindow) { const value = findCursorSelectOptionValue( contextOption, (option) => normalizeCursorConfigOptionToken(option.value) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow) || + normalizeCursorConfigOptionToken(requestedContextWindow) || normalizeCursorConfigOptionToken(option.name) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow), + normalizeCursorConfigOptionToken(requestedContextWindow), ); if (value) { updates.push({ configId: contextOption.id, value }); @@ -428,8 +503,9 @@ export function resolveCursorAcpConfigUpdates( const fastOption = configOptions.find( (option) => option.category === "model_config" && isCursorFastConfigOption(option), ); - if (fastOption && typeof modelOptions?.fastMode === "boolean") { - const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); + const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); if (value !== undefined) { updates.push({ configId: fastOption.id, value }); } @@ -438,8 +514,9 @@ export function resolveCursorAcpConfigUpdates( const thinkingOption = configOptions.find( (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), ); - if (thinkingOption && typeof modelOptions?.thinking === "boolean") { - const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); + const requestedThinking = getProviderOptionBooleanSelectionValue(selections, "thinking"); + if (thinkingOption && typeof requestedThinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); if (value !== undefined) { updates.push({ configId: thinkingOption.id, value }); } @@ -610,6 +687,7 @@ export function buildCursorProviderSnapshot(input: { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: input.cursorSettings.enabled, checkedAt: input.checkedAt, models: providerModelsFromSettings( @@ -962,6 +1040,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: false, checkedAt, models: fallbackModels, @@ -985,6 +1064,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( const error = aboutProbe.failure; return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, @@ -1003,6 +1083,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (Option.isNone(aboutProbe.success)) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, @@ -1025,6 +1106,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (parameterizedModelPickerUnsupportedMessage) { return buildServerProvider({ provider: PROVIDER, + presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 61c78068a4d..68ea7d4433d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -13,6 +13,7 @@ import { } from "@marcode/contracts"; import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import { getModelSelectionStringOptionValue } from "@marcode/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1164,11 +1165,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const agent = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.agent + ? getModelSelectionStringOptionValue(input.modelSelection, "agent") : undefined; const variant = input.modelSelection?.provider === PROVIDER - ? input.modelSelection.options?.variant + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") : undefined; context.activeTurnId = turnId; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 4a43c37b686..f32fd6f49e2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -176,14 +176,16 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); assert.ok(model); - assert.equal( - model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value, - "medium", + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", ); - assert.equal( - model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value, - "build", + assert.ok(variantDescriptor && variantDescriptor.type === "select"); + assert.equal(variantDescriptor.options.find((option) => option.isDefault)?.id, "medium"); + const agentDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); + assert.ok(agentDescriptor && agentDescriptor.type === "select"); + assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 9d7532178e2..f543b21d8f0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -6,6 +6,8 @@ import type { } from "@marcode/contracts"; import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; +import { createModelCapabilities } from "@marcode/shared/model"; + import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -25,6 +27,10 @@ import { import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = "opencode" as const; +const OPENCODE_PRESENTATION = { + displayName: "OpenCode", + showInteractionModeToggle: false, +} as const; const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ @@ -159,13 +165,9 @@ function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); function openCodeCapabilitiesForModel(input: { readonly providerID: string; @@ -174,27 +176,46 @@ function openCodeCapabilitiesForModel(input: { }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) => - Object.assign( - { value, label: titleCaseSlug(value) }, - defaultVariant === value ? { isDefault: true } : {}, - ), + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) => - Object.assign( - { value: agent.name, label: titleCaseSlug(agent.name) }, - defaultAgent === agent.name ? { isDefault: true } : {}, - ), + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, ); - return { - ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ...(variantOptions.length > 0 ? { variantOptions } : {}), - ...(agentOptions.length > 0 ? { agentOptions } : {}), - }; + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Variant", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); } function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { @@ -242,6 +263,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server if (!openCodeSettings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models, @@ -260,6 +282,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, @@ -296,6 +319,7 @@ export const OpenCodeProviderLive = Layer.effect( }); return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: input.settings.enabled, checkedAt, models: providerModelsFromSettings( @@ -317,6 +341,7 @@ export const OpenCodeProviderLive = Layer.effect( if (!input.settings.enabled) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, models: providerModelsFromSettings( @@ -368,6 +393,7 @@ export const OpenCodeProviderLive = Layer.effect( if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: input.settings.enabled, checkedAt, models: providerModelsFromSettings( @@ -433,6 +459,7 @@ export const OpenCodeProviderLive = Layer.effect( const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ provider: PROVIDER, + presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index acba05ea775..dabe5b8581e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -19,6 +19,7 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { createBuiltInAdapterList } from "../builtInProviderCatalog.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -31,12 +32,12 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [ - yield* ClaudeAdapter, - yield* CodexAdapter, - yield* OpenCodeAdapter, - ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []), - ]; + : createBuiltInAdapterList({ + codex: yield* CodexAdapter, + claudeAgent: yield* ClaudeAdapter, + opencode: yield* OpenCodeAdapter, + ...(cursorAdapterOption._tag === "Some" ? { cursor: cursorAdapterOption.value } : {}), + }); const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9939d8d49c9..aab66765a8c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -11,6 +11,7 @@ import { import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { deepMerge } from "@marcode/shared/Struct"; +import { createModelCapabilities } from "@marcode/shared/model"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; @@ -23,12 +24,36 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; -process.env.MARCODE_CURSOR_ENABLED = "1"; +process.env.T3CODE_CURSOR_ENABLED = "1"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -88,16 +113,15 @@ function failingSpawnerLayer(description: string) { ); } -const codexModelCapabilities = { - reasoningEffortLevels: [ - { value: "high", label: "High", isDefault: true }, - { value: "low", label: "Low" }, +const codexModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + { id: "low", label: "Low" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} satisfies NonNullable; +}) satisfies NonNullable; function makeCodexProbeSnapshot( input: Partial = {}, @@ -330,13 +354,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], slashCommands: [], @@ -367,13 +393,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), }, ], slashCommands: [], @@ -387,13 +415,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( slug: "claude-opus-4-6", name: "Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), }, ], } satisfies ServerProvider; @@ -603,9 +627,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", ); } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); assert.deepStrictEqual( - opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), - { value: "xhigh", label: "Extra High", isDefault: true }, + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index df8f857f36a..317cadc337a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -25,13 +25,8 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; - -type ProviderSnapshotSource = { - readonly provider: ProviderKind; - readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; - readonly streamChanges: Stream.Stream; -}; +import { createBuiltInProviderSources } from "../builtInProviderCatalog.ts"; +import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -41,11 +36,7 @@ const loadProviders = ( }); const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0; + (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const mergeProviderModels = ( previousModels: ReadonlyArray, @@ -98,32 +89,12 @@ const ProviderRegistryLiveBase = Layer.effect( const cursorProvider = yield* CursorProvider; - const providerSources = [ - { - provider: "codex", - getSnapshot: codexProvider.getSnapshot, - refresh: codexProvider.refresh, - streamChanges: codexProvider.streamChanges, - }, - { - provider: "claudeAgent", - getSnapshot: claudeProvider.getSnapshot, - refresh: claudeProvider.refresh, - streamChanges: claudeProvider.streamChanges, - }, - { - provider: "opencode", - getSnapshot: openCodeProvider.getSnapshot, - refresh: openCodeProvider.refresh, - streamChanges: openCodeProvider.streamChanges, - }, - { - provider: "cursor", - getSnapshot: cursorProvider.getSnapshot, - refresh: cursorProvider.refresh, - streamChanges: cursorProvider.streamChanges, - }, - ] satisfies ReadonlyArray; + const providerSources = createBuiltInProviderSources({ + codex: codexProvider, + claudeAgent: claudeProvider, + opencode: openCodeProvider, + cursor: cursorProvider, + }) satisfies ReadonlyArray; const activeProviders = PROVIDER_CACHE_IDS; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index cb030adbf41..ba94eab41a3 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -892,9 +892,7 @@ routing.layer("ProviderServiceLive routing", (it) => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, runtimeMode: "full-access", }); @@ -925,9 +923,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.deepEqual(startPayload.modelSelection, { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 94de569b2b2..30941acbfd5 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -99,11 +99,11 @@ describe("applyCursorAcpModelSelection", () => { applyCursorAcpModelSelection({ runtime, model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", - modelOptions: { - reasoning: "xhigh", - contextWindow: "1m", - fastMode: true, - }, + selections: [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], mapError: ({ step, configId, cause }) => new Error( step === "set-config-option" diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index fcf5aaf8fd7..13019c1fc68 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,4 @@ -import { type CursorModelOptions, type CursorSettings } from "@marcode/contracts"; +import { type CursorSettings, type ProviderOptionSelection } from "@marcode/contracts"; import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; @@ -76,7 +76,7 @@ interface CursorAcpModelSelectionRuntime { export function applyCursorAcpModelSelection(input: { readonly runtime: CursorAcpModelSelectionRuntime; readonly model: string | null | undefined; - readonly modelOptions: CursorModelOptions | null | undefined; + readonly selections: ReadonlyArray | null | undefined; readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; }): Effect.Effect { return Effect.gen(function* () { @@ -91,7 +91,7 @@ export function applyCursorAcpModelSelection(input: { const configUpdates = resolveCursorAcpConfigUpdates( yield* input.runtime.getConfigOptions, - input.modelOptions, + input.selections, ); for (const update of configUpdates) { yield* input.runtime.setConfigOption(update.configId, update.value).pipe( diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts new file mode 100644 index 00000000000..b986ac2bb1a --- /dev/null +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -0,0 +1,49 @@ +import type { ProviderKind, ServerProvider } from "@marcode/contracts"; +import type { Stream } from "effect"; +import type { ProviderAdapterError } from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +export type ProviderSnapshotSource = { + readonly provider: ProviderKind; + readonly getSnapshot: ServerProviderShape["getSnapshot"]; + readonly refresh: ServerProviderShape["refresh"]; + readonly streamChanges: Stream.Stream; +}; + +type BuiltInProviderServiceMap = Record; +type BuiltInAdapterMap = { + readonly codex: ProviderAdapterShape; + readonly claudeAgent: ProviderAdapterShape; + readonly opencode: ProviderAdapterShape; + readonly cursor?: ProviderAdapterShape; +}; + +export const BUILT_IN_PROVIDER_ORDER = [ + "claudeAgent", + "codex", + "opencode", + "cursor", +] as const satisfies ReadonlyArray; + +export function createBuiltInProviderSources( + services: BuiltInProviderServiceMap, +): ReadonlyArray { + return BUILT_IN_PROVIDER_ORDER.map((provider) => ({ + provider, + getSnapshot: services[provider].getSnapshot, + refresh: services[provider].refresh, + streamChanges: services[provider].streamChanges, + })); +} + +export function createBuiltInAdapterList( + adapters: BuiltInAdapterMap, +): ReadonlyArray> { + return [ + adapters.claudeAgent, + adapters.codex, + adapters.opencode, + ...(adapters.cursor ? [adapters.cursor] : []), + ]; +} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 0d42c59e206..59233cb2d65 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,9 +1,21 @@ import { describe, it, assert } from "@effect/vitest"; import type { ServerProvider } from "@marcode/contracts"; +import { createModelCapabilities } from "@marcode/shared/model"; import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const fastModeCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], +}); + interface TestSettings { readonly enabled: boolean; } @@ -43,13 +55,7 @@ const enrichedSnapshot: ServerProvider = { slug: "composer-2", name: "Composer 2", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: fastModeCapabilities, }, ], }; @@ -68,13 +74,7 @@ const enrichedSnapshotSecond: ServerProvider = { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], }; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 193ed6e87c2..0b3efeda7cb 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -36,7 +36,6 @@ import { NetService } from "@marcode/shared/Net"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; const DEFAULT_HOSTNAME = "127.0.0.1"; - export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; @@ -515,7 +514,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); export class OpenCodeRuntime extends Context.Service()( - "marcode/provider/OpenCodeRuntime", + "t3/provider/OpenCodeRuntime", ) {} export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index db39bee1a28..8e4b9c5ebdd 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,17 +1,27 @@ import { describe, expect, it } from "vitest"; import type { ModelCapabilities } from "@marcode/contracts"; +import { createModelCapabilities } from "@marcode/shared/model"; import { providerModelsFromSettings } from "./providerSnapshot.ts"; -const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], - agentOptions: [{ value: "build", label: "Build", isDefault: true }], -}; +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build", isDefault: true }], + currentValue: "build", + }, + ], +}); describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 19c26774919..01dc017a454 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -30,6 +30,12 @@ export interface ProviderProbeResult { readonly message?: string; } +export interface ServerProviderPresentation { + readonly displayName: string; + readonly badgeLabel?: string; + readonly showInteractionModeToggle?: boolean; +} + export function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -129,8 +135,52 @@ export function providerModelsFromSettings( return [...resolvedBuiltInModels, ...customEntries]; } +export function buildSelectOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly options: + | ReadonlyArray<{ value: string; label: string; isDefault?: boolean | undefined }> + | undefined; + readonly description?: string; + readonly promptInjectedValues?: ReadonlyArray; +}) { + const options = (input.options ?? []).map((option) => + option.isDefault + ? { id: option.value, label: option.label, isDefault: true } + : { id: option.value, label: option.label }, + ); + const currentValue = options.find((option) => option.isDefault)?.id; + return { + id: input.id, + label: input.label, + type: "select" as const, + options, + ...(currentValue ? { currentValue } : {}), + ...(input.description ? { description: input.description } : {}), + ...(input.promptInjectedValues && input.promptInjectedValues.length > 0 + ? { promptInjectedValues: [...input.promptInjectedValues] } + : {}), + }; +} + +export function buildBooleanOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly currentValue?: boolean; + readonly description?: string; +}) { + return { + id: input.id, + label: input.label, + type: "boolean" as const, + ...(input.description ? { description: input.description } : {}), + ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}), + }; +} + export function buildServerProvider(input: { provider: ServerProvider["provider"]; + presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; models: ReadonlyArray; @@ -140,6 +190,11 @@ export function buildServerProvider(input: { }): ServerProvider { return { provider: input.provider, + displayName: input.presentation.displayName, + ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), + ...(typeof input.presentation.showInteractionModeToggle === "boolean" + ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 6cd7678f869..31322303a27 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ServerProvider } from "@marcode/contracts"; +import { createModelCapabilities } from "@marcode/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; @@ -10,6 +11,8 @@ import { writeProviderStatusCache, } from "./providerStatusCache.ts"; +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); + const makeProvider = ( provider: ServerProvider["provider"], overrides?: Partial, @@ -81,13 +84,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Cached message", @@ -106,13 +103,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], message: "Pending refresh", @@ -131,13 +122,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { slug: "gpt-5-mini", name: "GPT-5 Mini", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: emptyCapabilities, }, ], installed: cachedCodex.installed, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 17cf40bc57d..eb3bb9f8efb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@marcode/contracts"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "@marcode/contracts"; +import { createModelSelection } from "@marcode/shared/model"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; import { ServerConfig } from "./config.ts"; @@ -10,7 +11,7 @@ const makeServerSettingsLayer = () => Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { - prefix: "marcode-server-settings-test-", + prefix: "t3code-server-settings-test-", }), ), ), @@ -28,22 +29,40 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual( decodePatch({ textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }), { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }, ); }), ); + it.effect( + "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", + () => + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(ServerSettings); + + const decoded = decode({ + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + options: { reasoningEffort: "low" }, + }, + }); + + assert.deepEqual(decoded.textGenerationModelSelection, { + provider: "codex", + model: "gpt-5.4-mini", + options: [{ id: "reasoningEffort", value: "low" }], + }); + }), + ); + it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -62,10 +81,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + "codex", + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); @@ -76,9 +99,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }); @@ -94,14 +115,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: ["claude-custom"], launchArgs: "", }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: false, - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection("codex", DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ]), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -114,9 +134,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "high", - }, + options: createModelSelection("claudeAgent", "claude-sonnet-4-6", [ + { id: "effort", value: "high" }, + ]).options!, }, }); @@ -126,19 +146,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: "gpt-5.4", - options: { - reasoningEffort: "high", - }, + options: createModelSelection("codex", "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]).options!, }, }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "high", - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection("codex", "gpt-5.4", [{ id: "reasoningEffort", value: "high" }]), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -150,10 +167,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { textGenerationModelSelection: { provider: "codex", model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + "codex", + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); @@ -254,24 +275,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("round-trips addProjectBaseDirectory through patch + decode", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "/Users/tester/Projects", - }); - - assert.equal(next.addProjectBaseDirectory, "/Users/tester/Projects"); - - const followup = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "A", - }); - - assert.equal(followup.addProjectBaseDirectory, "A"); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - it.effect("writes only non-default server settings to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; From e2601893d666b1b3aa028de25fb3c66503c6ec65 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 15:39:19 +0300 Subject: [PATCH 4/7] feat(server): retrofit git text-generation layers to option-array shape (upstream #2246 commit D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage four of five. Retrofits every provider-specific modelSelection.options access site in the git-text-generation layers to the provider-agnostic helpers (getModelSelectionStringOptionValue / getModelSelectionBooleanOptionValue / getProviderOptionDescriptors) introduced in Commit A. ClaudeTextGeneration: - Replaces removed normalizeClaudeModelOptionsWithCapabilities helper with the descriptor-based pattern: getProviderOptionDescriptors(selections) + resolveClaudeEffort + normalizeClaudeCliEffort for effort CLI arg, plus typed fastMode / thinking currentValue lookups for the --settings JSON. - Preserves MarCode's fork-exclusive progressive generation code path, --tools "" (tool-lockdown) spawn arg, and Claude-first branching — only the option access layer changed. CodexTextGeneration: - Pulls reasoningEffort / fastMode via the helpers; falls back to the DEFAULT_CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT constant. CursorTextGeneration: - Renames the applyCursorAcpModelSelection argument from `modelOptions` to `selections` (upstream's new parameter name). OpenCodeTextGeneration: - Agent / variant read via getModelSelectionStringOptionValue. RoutingTextGeneration: untouched — it forwards the opaque modelSelection to its sub-layers and never peeks at `options`, so the Claude→Codex fallback routing (FEATURES.md §"Claude-Powered Text Generation") survives without structural changes. Upstream's object-lookup dispatch rewrite was intentionally NOT ported, per the plan ("do not port upstream's structural rewrites"), because MarCode's if/else branching has different semantics. Test literals migrated from `{ options: { effort: "max" } }` to the new array shape in ClaudeTextGeneration / CodexTextGeneration / CursorTextGeneration specs, plus ProviderCommandReactor.test.ts and decider.projectScripts.test.ts. Exit criterion: `(cd apps/server && bun run test)` all-green — 343/343 in git + orchestration, 5/5 in decider, 244/244 in provider (from Commit C), 5/5 migration (from Commit B). Full apps/server typecheck is now clean (remaining errors live in apps/web, Commit E). --- .../git/Layers/ClaudeTextGeneration.test.ts | 16 ++--- .../src/git/Layers/ClaudeTextGeneration.ts | 40 +++++++++---- .../git/Layers/CodexTextGeneration.test.ts | 8 +-- .../src/git/Layers/CodexTextGeneration.ts | 10 +++- .../git/Layers/CursorTextGeneration.test.ts | 10 ++-- .../src/git/Layers/CursorTextGeneration.ts | 2 +- .../src/git/Layers/OpenCodeTextGeneration.ts | 11 ++-- .../Layers/ProviderCommandReactor.test.ts | 60 +++++++------------ .../decider.projectScripts.test.ts | 16 ++--- 9 files changed, 88 insertions(+), 85 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index c37e041da33..9ec01e879b5 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -202,10 +202,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { modelSelection: { provider: "claudeAgent", model: "claude-haiku-4-5", - options: { - thinking: false, - effort: "high", - }, + options: [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ], }, }); @@ -265,10 +265,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], }, }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 132e85dd977..15f9581b950 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -29,10 +29,17 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@marcode/shared/model"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@marcode/shared/model"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -85,15 +92,24 @@ const makeClaudeTextGeneration = Effect.gen(function* () { modelSelection: ClaudeModelSelection; }): Effect.fn.Return { const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); - const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( - getClaudeModelCapabilities(modelSelection.model), - modelSelection.options, - ); + const caps = getClaudeModelCapabilities(modelSelection.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); + const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); + const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); + const thinkingDescriptor = findDescriptor("thinking"); + const fastModeDescriptor = findDescriptor("fastMode"); + const thinking = + thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + const fastMode = + fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { - ...(typeof normalizedOptions?.thinking === "boolean" - ? { alwaysThinkingEnabled: normalizedOptions.thinking } - : {}), - ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), }; const claudeSettings = yield* Effect.map( @@ -112,7 +128,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { jsonSchemaStr, "--model", resolveClaudeApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(cliEffort ? ["--effort", cliEffort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--tools", "", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index ce249b819f7..1251eb2bc20 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -247,10 +247,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { modelSelection: { provider: "codex", model: "gpt-5.4", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ], }, }); }), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index dfda43f13c1..3925cff4aa8 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -5,6 +5,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@marcode/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@marcode/shared/git"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@marcode/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -156,7 +160,9 @@ const makeCodexTextGeneration = Effect.gen(function* () { const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const reasoningEffort = - modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + const fastMode = getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true; const command = ChildProcess.make( codexSettings?.binaryPath || "codex", [ @@ -169,7 +175,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []), + ...(fastMode ? ["--config", `service_tier="fast"`] : []), "--output-schema", schemaPath, "--output-last-message", diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index 54f92cf6d4c..b9acff25086 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -137,11 +137,11 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { modelSelection: { provider: "cursor", model: "gpt-5.4", - options: { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }, + options: [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ], }, }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 44630ea21cf..d1ef40c9c69 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { yield* applyCursorAcpModelSelection({ runtime, model: modelSelection.model, - modelOptions: modelSelection.options, + selections: modelSelection.options, mapError: ({ cause, configId, step }) => mapCursorAcpError( operation, diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index bb9caf4a79e..4be7711eda1 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -7,6 +7,7 @@ import { type OpenCodeModelSelection, } from "@marcode/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@marcode/shared/git"; +import { getModelSelectionStringOptionValue } from "@marcode/shared/model"; import { ServerConfig } from "../../config.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -321,15 +322,13 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { throw new Error("OpenCode session.create returned no session payload."); } + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); const result = await client.session.prompt({ sessionID: session.data.id, model: parsedModel, - ...(input.modelSelection.options?.agent - ? { agent: input.modelSelection.options.agent } - : {}), - ...(input.modelSelection.options?.variant - ? { variant: input.modelSelection.options.variant } - : {}), + ...(agent ? { agent } : {}), + ...(variant ? { variant } : {}), parts: [{ type: "text", text: input.prompt }, ...fileParts], }); const info = result.data?.info; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8c97dcfbb06..ad5e878214a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -577,10 +577,10 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -594,10 +594,10 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ @@ -605,10 +605,10 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, }); }); @@ -633,9 +633,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -649,9 +647,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ @@ -659,9 +655,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, }); }); @@ -686,9 +680,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - fastMode: true, - }, + options: [{ id: "fastMode", value: true }], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -702,9 +694,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - fastMode: true, - }, + options: [{ id: "fastMode", value: true }], }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ @@ -712,9 +702,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - fastMode: true, - }, + options: [{ id: "fastMode", value: true }], }, }); }); @@ -1004,9 +992,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "medium", - }, + options: [{ id: "effort", value: "medium" }], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -1031,9 +1017,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -1048,9 +1032,7 @@ describe("ProviderCommandReactor", () => { modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6", - options: { - effort: "max", - }, + options: [{ id: "effort", value: "max" }], }, }); }); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 9be1b0547d6..51b55d76743 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -167,10 +167,10 @@ describe("decider project scripts", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -196,10 +196,10 @@ describe("decider project scripts", () => { modelSelection: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }, runtimeMode: "approval-required", }); From 5f75e700d2d801a3be2b07afe028eaace5b77a7d Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 16:23:26 +0300 Subject: [PATCH 5/7] feat(web): port composer to option-array shape (upstream #2246 commit E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage five of five — the final stage of the upstream #2246 port. Migrates the web composer from provider-specific option objects (`{ effort, fastMode }`) to the provider-agnostic array shape (`[{ id, value }]`). Structural changes: - Delete composerProviderRegistry.tsx + test (gone upstream) and replace with composerProviderState.tsx + test (upstream's descriptor-driven implementation, rebranded to @marcode/*). - Delete TraitsPicker.browser.tsx (gone upstream; no MarCode-unique coverage was there — styling-only fork divergence). TraitsPicker.tsx absorbs the descriptor-based control rendering. - composerDraftStore.ts: drop CursorModelOptions / CursorReasoningOption / CURSOR_REASONING_OPTIONS / ClaudeAgentEffort / CodexReasoningEffort / ProviderModelOptions imports. Introduce local ProviderOptionSelectionsByProvider alias + coerceProviderOptionSelections helper. All MarCode store additions preserved: stickyModelSelectionByProvider, terminalContexts, jiraTaskContexts, quotedContexts, draft thread/project shims, voice-prompting state. Retrofit only (no structural rewrite) in: - ChatView.tsx: formatOutgoingPrompt now uses resolvePromptInjectedEffort; composerProviderControls uses getProviderInteractionModeToggle(providerStatuses). - ChatComposer.tsx: same wiring, plus modelOptions passed as composerModelOptions?.[selectedProvider] instead of the whole by-provider map. - ProviderModelPicker.browser.tsx: 5 capability blocks converted from {reasoningEffortLevels, supportsFastMode, ...} to {optionDescriptors: [...]}; MarCode's Cmd-K redesign (+578 LoC over merge-base from #2153 port) kept intact. - CompactComposerControlsMenu.browser.tsx: same descriptor conversion + 3 literal `options` object -> array migrations. - ChatView.browser.tsx: test fixtures via createModelSelection + createModelCapabilities; expect.arrayContaining for sticky-option assertions (matchObject compares arrays strictly, so we pin the relevant sticky trait and ignore others). - modelSelection.ts, providerModels.ts: import + shape shift only. providerModels.ts adds getProviderDisplayName / getProviderInteractionModeToggle / formatProviderKindLabel (upstream-added, used downstream). - SettingsPanels.tsx: capability labels derived from descriptors (fastMode / thinking / effort-or-reasoning presence). - useSettings.ts: NonNullable cast on textGenerationModelSelection assignment to satisfy exactOptionalPropertyTypes. MarCode customizations preserved: - DEFAULT_PROVIDER_KIND = "claudeAgent" (not upstream's "codex") - All fork-exclusive store fields (Jira chip, voice, terminal contexts, sticky per-provider model selection) - Cmd-K ProviderModelPicker redesign - Voice-prompting flow, sticky model selection behaviors Exit criterion (plan §Commit E): - bun run typecheck: clean across all 10 packages - (cd apps/web && bun run test): 1084/1084 passing All five stages of the #2246 port are now on branch. Regression guard sweep (Phase 3) and migration fixture smoke (Phase 4) come next, then PR. --- apps/web/src/components/ChatView.browser.tsx | 84 +- apps/web/src/components/ChatView.tsx | 23 +- apps/web/src/components/chat/ChatComposer.tsx | 20 +- .../CompactComposerControlsMenu.browser.tsx | 79 +- .../chat/ProviderModelPicker.browser.tsx | 138 +-- .../components/chat/TraitsPicker.browser.tsx | 829 ------------------ apps/web/src/components/chat/TraitsPicker.tsx | 500 ++++------- .../chat/composerProviderRegistry.test.tsx | 516 ----------- .../chat/composerProviderRegistry.tsx | 218 ----- .../chat/composerProviderState.test.tsx | 242 +++++ .../components/chat/composerProviderState.tsx | 108 +++ .../components/settings/SettingsPanels.tsx | 18 +- apps/web/src/composerDraftStore.test.ts | 114 ++- apps/web/src/composerDraftStore.ts | 343 +++----- apps/web/src/hooks/useSettings.ts | 5 +- apps/web/src/modelSelection.ts | 6 +- apps/web/src/providerModels.ts | 68 +- 17 files changed, 991 insertions(+), 2320 deletions(-) delete mode 100644 apps/web/src/components/chat/TraitsPicker.browser.tsx delete mode 100644 apps/web/src/components/chat/composerProviderRegistry.test.tsx delete mode 100644 apps/web/src/components/chat/composerProviderRegistry.tsx create mode 100644 apps/web/src/components/chat/composerProviderState.test.tsx create mode 100644 apps/web/src/components/chat/composerProviderState.tsx diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index c8291277a7c..23d9a88b8a3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3998,10 +3998,10 @@ describe("ChatView timeline estimator parity (full app)", () => { codex: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], }, }, stickyActiveProvider: "codex", @@ -4033,9 +4033,7 @@ describe("ChatView timeline estimator parity (full app)", () => { codex: { provider: "codex", model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, }, activeProvider: "codex", @@ -4051,10 +4049,10 @@ describe("ChatView timeline estimator parity (full app)", () => { claudeAgent: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], }, }, stickyActiveProvider: "claudeAgent", @@ -4086,10 +4084,10 @@ describe("ChatView timeline estimator parity (full app)", () => { claudeAgent: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, + options: expect.arrayContaining([ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), }, }, activeProvider: "claudeAgent", @@ -4133,10 +4131,10 @@ describe("ChatView timeline estimator parity (full app)", () => { codex: { provider: "codex", model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], }, }, stickyActiveProvider: "codex", @@ -4168,9 +4166,7 @@ describe("ChatView timeline estimator parity (full app)", () => { codex: { provider: "codex", model: "gpt-5.3-codex", - options: { - fastMode: true, - }, + options: expect.arrayContaining([{ id: "fastMode", value: true }]), }, }, activeProvider: "codex", @@ -4179,10 +4175,10 @@ describe("ChatView timeline estimator parity (full app)", () => { useComposerDraftStore.getState().setModelSelection(draftId, { provider: "codex", model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ], }); await newThreadButton.click(); @@ -5929,11 +5925,13 @@ describe("ChatView timeline estimator parity (full app)", () => { name: "GPT-5.1 Codex Max", isCustom: false, capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], + optionDescriptors: [ + { + id: "fastMode", + label: "Fast mode", + type: "boolean" as const, + }, + ], }, }, { @@ -5941,11 +5939,13 @@ describe("ChatView timeline estimator parity (full app)", () => { name: "GPT-5.3 Codex", isCustom: false, capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], + optionDescriptors: [ + { + id: "fastMode", + label: "Fast mode", + type: "boolean" as const, + }, + ], }, }, { @@ -5953,11 +5953,13 @@ describe("ChatView timeline estimator parity (full app)", () => { name: "GPT-5.4", isCustom: false, capabilities: { - supportsFastMode: true, - supportsThinkingToggle: false, - reasoningEffortLevels: [], - promptInjectedEffortLevels: [], - contextWindowOptions: [], + optionDescriptors: [ + { + id: "fastMode", + label: "Fast mode", + type: "boolean" as const, + }, + ], }, }, ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8865c38874e..45c64548f8f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,7 +2,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_KIND, - type ClaudeAgentEffort, type EnvironmentId, type MessageId, type ModelSelection, @@ -34,6 +33,7 @@ import { } from "@marcode/client-runtime"; import { applyClaudePromptEffortPrefix, + resolvePromptInjectedEffort, createModelSelection, normalizeModelSlug, } from "@marcode/shared/model"; @@ -168,6 +168,7 @@ import { SidebarTrigger, useSidebar } from "./ui/sidebar"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { + getProviderInteractionModeToggle, getProviderModelCapabilities, getProviderModels, resolveSelectableProvider, @@ -251,11 +252,10 @@ import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPa import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { SubagentDetailDrawer } from "./chat/SubagentDetailDrawer"; import { - getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, -} from "./chat/composerProviderRegistry"; +} from "./chat/composerProviderState"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -393,10 +393,8 @@ function formatOutgoingPrompt(params: { text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); - } - return params.text; + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); } const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; @@ -1343,15 +1341,20 @@ export default function ChatView({ model: selectedModel, models: selectedProviderModels, prompt, - modelOptions: composerModelOptions, + modelOptions: composerModelOptions?.[selectedProvider], }), [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const composerProviderControls = useMemo( - () => getComposerProviderControls(selectedProvider), - [selectedProvider], + () => ({ + showInteractionModeToggle: getProviderInteractionModeToggle( + providerStatuses, + selectedProvider, + ), + }), + [providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8b8ffcb1ff0..124c003e46c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -71,11 +71,10 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { - getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, -} from "./composerProviderRegistry"; +} from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-icons"; @@ -96,7 +95,11 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; +import { + getProviderInteractionModeToggle, + getProviderModels, + resolveSelectableProvider, +} from "../../providerModels"; import type { UnifiedSettings } from "@marcode/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; @@ -596,7 +599,7 @@ export const ChatComposer = memo( model: selectedModel, models: selectedProviderModels, prompt, - modelOptions: composerModelOptions, + modelOptions: composerModelOptions?.[selectedProvider], }), [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], ); @@ -604,8 +607,13 @@ export const ChatComposer = memo( const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const composerProviderControls = useMemo( - () => getComposerProviderControls(selectedProvider), - [selectedProvider], + () => ({ + showInteractionModeToggle: getProviderInteractionModeToggle( + providerStatuses, + selectedProvider, + ), + }), + [providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 97a4e222a0d..242e432bed5 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -61,17 +61,22 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str name: "Opus 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + { id: "fastMode", label: "Fast mode", type: "boolean" as const }, ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], }, }, { @@ -79,11 +84,9 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str name: "Haiku 4.5", isCustom: false, capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + { id: "thinking", label: "Thinking", type: "boolean" as const }, + ], }, }, { @@ -91,16 +94,20 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str name: "Sonnet 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], }, }, ] @@ -110,14 +117,18 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str name: "GPT-5.4", isCustom: false, capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning effort", + type: "select" as const, + options: [ + { id: "xhigh", label: "Extra High" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + { id: "fastMode", label: "Fast mode", type: "boolean" as const }, ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], }, }, ]; @@ -215,7 +226,7 @@ describe("CompactComposerControlsMenu", () => { modelSelection: { provider: "claudeAgent", model: "claude-haiku-4-5", - options: { thinking: true }, + options: [{ id: "thinking", value: true }], }, }); @@ -234,7 +245,7 @@ describe("CompactComposerControlsMenu", () => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { effort: "high" }, + options: [{ id: "effort", value: "high" }], }, prompt: "Ultrathink:\nInvestigate this", }); @@ -253,7 +264,7 @@ describe("CompactComposerControlsMenu", () => { modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", - options: { effort: "high" }, + options: [{ id: "effort", value: "high" }], }, prompt: "Ultrathink:\nplease ultrathink about this problem", }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 5e12a1e0bb1..46e00eeb901 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -68,12 +68,36 @@ vi.mock("../../environments/runtime", () => { function effort(value: string, isDefault = false) { return { - value, + id: value, label: value, ...(isDefault ? { isDefault: true } : {}), }; } +function buildEffortDescriptor( + id: "reasoningEffort" | "effort", + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label: id === "effort" ? "Effort" : "Reasoning effort", + type: "select" as const, + options, + }; +} + +const FAST_MODE_DESCRIPTOR = { + id: "fastMode", + label: "Fast mode", + type: "boolean" as const, +}; + +const THINKING_DESCRIPTOR = { + id: "thinking", + label: "Thinking", + type: "boolean" as const, +}; + const TEST_PROVIDERS: ReadonlyArray = [ { provider: "codex", @@ -91,11 +115,14 @@ const TEST_PROVIDERS: ReadonlyArray = [ name: "GPT-5 Codex", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("reasoningEffort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + FAST_MODE_DESCRIPTOR, + ], }, }, { @@ -103,11 +130,14 @@ const TEST_PROVIDERS: ReadonlyArray = [ name: "GPT-5.3 Codex", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("reasoningEffort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + FAST_MODE_DESCRIPTOR, + ], }, }, ], @@ -128,16 +158,15 @@ const TEST_PROVIDERS: ReadonlyArray = [ name: "Claude Opus 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), + optionDescriptors: [ + buildEffortDescriptor("effort", [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ]), + THINKING_DESCRIPTOR, ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], }, }, { @@ -145,16 +174,15 @@ const TEST_PROVIDERS: ReadonlyArray = [ name: "Claude Sonnet 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("max"), + optionDescriptors: [ + buildEffortDescriptor("effort", [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ]), + THINKING_DESCRIPTOR, ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], }, }, { @@ -162,11 +190,14 @@ const TEST_PROVIDERS: ReadonlyArray = [ name: "Claude Haiku 4.5", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("effort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + THINKING_DESCRIPTOR, + ], }, }, ], @@ -735,11 +766,14 @@ describe("ProviderModelPicker", () => { name: "GPT-5.3 Codex", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("reasoningEffort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + FAST_MODE_DESCRIPTOR, + ], }, }, ]), @@ -752,11 +786,14 @@ describe("ProviderModelPicker", () => { name: "GPT-5.3 Codex", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("reasoningEffort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + FAST_MODE_DESCRIPTOR, + ], }, }, { @@ -764,11 +801,14 @@ describe("ProviderModelPicker", () => { name: "GPT-5.3 Codex Spark", isCustom: false, capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + optionDescriptors: [ + buildEffortDescriptor("reasoningEffort", [ + effort("low"), + effort("medium", true), + effort("high"), + ]), + FAST_MODE_DESCRIPTOR, + ], }, }, ]), diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx deleted file mode 100644 index c11451d0dd9..00000000000 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ /dev/null @@ -1,829 +0,0 @@ -import "../../index.css"; - -import { - type ModelSelection, - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_SERVER_SETTINGS, - OpenCodeModelOptions, - EnvironmentId, - type ServerProvider, - ThreadId, -} from "@marcode/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@marcode/client-runtime"; -import { page } from "vitest/browser"; -import { useCallback } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { TraitsPicker } from "./TraitsPicker"; -import { - COMPOSER_DRAFT_STORAGE_KEY, - ComposerThreadDraftState, - useComposerDraftStore, - useComposerThreadDraft, - useEffectiveComposerModelState, -} from "../../composerDraftStore"; -import { DEFAULT_CLIENT_SETTINGS } from "@marcode/contracts/settings"; - -// ── Claude TraitsPicker tests ───────────────────────────────────────── - -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const CLAUDE_THREAD_ID = ThreadId.make("thread-claude-traits"); -const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); -const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); -const CODEX_THREAD_ID = ThreadId.make("thread-codex-traits"); -const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); -const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); -const TEST_PROVIDERS: ReadonlyArray = [ - { - provider: "codex", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, - { - provider: "opencode", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "openai/gpt-5", - name: "GPT-5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - ], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, - ], - }, - { - provider: "claudeAgent", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommands: [], - skills: [], - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, -]; -const findTestProvider = (provider: ServerProvider["provider"]) => { - const testProvider = TEST_PROVIDERS.find((candidate) => candidate.provider === provider); - if (!testProvider) { - throw new Error(`Missing test provider fixture: ${provider}`); - } - return testProvider; -}; - -function ClaudeTraitsPickerHarness(props: { - model: string; - fallbackModelSelection: ModelSelection | null; - triggerVariant?: "ghost" | "outline"; -}) { - const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; - const setPrompt = useComposerDraftStore((store) => store.setPrompt); - const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadRef: CLAUDE_THREAD_REF, - providers: TEST_PROVIDERS, - selectedProvider: "claudeAgent", - threadModelSelection: props.fallbackModelSelection, - projectModelSelection: null, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...DEFAULT_CLIENT_SETTINGS, - }, - }); - const handlePromptChange = useCallback( - (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_REF, nextPrompt); - }, - [setPrompt], - ); - - return ( - - ); -} - -async function mountClaudePicker(props?: { - model?: string; - prompt?: string; - options?: ClaudeModelOptions; - fallbackModelOptions?: { - effort?: "low" | "medium" | "high" | "max" | "ultrathink"; - thinking?: boolean; - fastMode?: boolean; - } | null; - skipDraftModelOptions?: boolean; - triggerVariant?: "ghost" | "outline"; -}) { - const model = props?.model ?? "claude-opus-4-6"; - const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; - const draftsByThreadKey: Record = { - [CLAUDE_THREAD_KEY]: { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - jiraTaskContexts: [], - quotedContexts: [], - modelSelectionByProvider: props?.skipDraftModelOptions - ? {} - : { - claudeAgent: { - provider: "claudeAgent", - model, - ...(claudeOptions && Object.keys(claudeOptions).length > 0 - ? { options: claudeOptions } - : {}), - }, - }, - activeProvider: "claudeAgent", - runtimeMode: null, - interactionMode: null, - }, - }; - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const fallbackModelSelection = - props?.fallbackModelOptions !== undefined - ? ({ - provider: "claudeAgent", - model, - ...(props.fallbackModelOptions ? { options: props.fallbackModelOptions } : {}), - } satisfies ModelSelection) - : null; - const screen = await render( - , - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Claude)", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls for Opus", async () => { - await using _ = await mountClaudePicker(); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("hides fast mode controls for non-Opus models", async () => { - await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountClaudePicker({ - model: "claude-sonnet-4-6", - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - }); - - it("shows a th inking on/off dropdown for Haiku", async () => { - await using _ = await mountClaudePicker({ - model: "claude-haiku-4-5", - options: { thinking: true }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Thinking On"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); - expect(text).toContain("Off"); - }); - }); - - it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "high" }, - prompt: "Ultrathink:\nInvestigate this", - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Ultrathink"); - expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Effort"); - expect(text).not.toContain("ultrathink"); - }); - }); - - it("warns when ultrathink appears in prompt body text", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "high" }, - prompt: "Ultrathink:\nplease ultrathink about this problem", - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', - ); - }); - }); - - it("persists sticky claude model options when traits change", async () => { - await using _ = await mountClaudePicker({ - model: "claude-opus-4-6", - options: { effort: "medium", fastMode: false }, - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Max" }).click(); - - expect( - useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, - ).toMatchObject({ - provider: "claudeAgent", - options: { - effort: "max", - }, - }); - }); - - it("accepts outline trigger styling", async () => { - await using _ = await mountClaudePicker({ - triggerVariant: "outline", - }); - - const button = document.querySelector("button"); - if (!(button instanceof HTMLButtonElement)) { - throw new Error("Expected traits trigger button to be rendered."); - } - expect(button.className).toContain("border-input"); - expect(button.className).toContain("bg-popover"); - }); -}); - -// ── Codex TraitsPicker tests ────────────────────────────────────────── - -async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; - const draftsByThreadKey: Record = { - [CODEX_THREAD_KEY]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - jiraTaskContexts: [], - quotedContexts: [], - modelSelectionByProvider: { - codex: { - provider: "codex", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "codex", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - "environment-local:project-codex-traits": CODEX_THREAD_KEY, - }, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) { - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor; - const cursorThreadId = ThreadId.make("thread-cursor-traits"); - const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId); - const cursorThreadKey = scopedThreadKey(cursorThreadRef); - const host = document.createElement("div"); - document.body.append(host); - - useComposerDraftStore.setState({ - draftsByThreadKey: { - [cursorThreadKey]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - jiraTaskContexts: [], - quotedContexts: [], - modelSelectionByProvider: { - cursor: { - provider: "cursor", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "cursor", - runtimeMode: null, - interactionMode: null, - }, - }, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Codex)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows fast mode controls", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("shows Fast in the trigger label when fast mode is active", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: true }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("High · Fast"); - }); - }); - - it("shows only the provided effort options", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Extra High"); - expect(text).toContain("High"); - expect(text).not.toContain("Low"); - expect(text).not.toContain("Medium"); - }); - }); - - it("persists sticky codex model options when traits change", async () => { - await using _ = await mountCodexPicker({ - options: { fastMode: false }, - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ - provider: "codex", - options: { fastMode: true }, - }); - }); -}); - -// ── OpenCode TraitsPicker tests ─────────────────────────────────────── - -async function mountOpenCodePicker(props: { - model?: string; - options?: OpenCodeModelOptions; - models?: ServerProvider["models"]; -}) { - const threadId = ThreadId.make("thread-opencode-traits"); - const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); - const threadKey = scopedThreadKey(threadRef); - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode; - const draftsByThreadKey: Record = { - [threadKey]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - jiraTaskContexts: [], - quotedContexts: [], - modelSelectionByProvider: { - opencode: { - provider: "opencode", - model, - ...(props.options ? { options: props.options } : {}), - }, - }, - activeProvider: "opencode", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (OpenCode)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows the selected agent label with capitalization in the trigger", async () => { - await using _ = await mountOpenCodePicker({ - options: { - variant: "medium", - agent: "plan", - }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Medium · Plan"); - expect(text).not.toContain("Medium · plan"); - }); - }); - - it("does not show a leading separator when only agent options are available", async () => { - await using _ = await mountOpenCodePicker({ - model: "openai/gpt-5.4", - models: [ - { - slug: "openai/gpt-5.4", - name: "OpenAI · GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, - ], - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Agent"); - expect(text).toContain("Build (default)"); - expect(text).toContain("Plan"); - expect(document.querySelectorAll('[data-slot="menu-separator"]')).toHaveLength(0); - }); - }); -}); - -describe("TraitsPicker (Cursor)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("uses the selected fast mode menu label for the trigger in fast-only state", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - options: { fastMode: false }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - }); - - it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => { - await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); - }); - - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("Context Window"); - expect(text).toContain("200K (default)"); - expect(text).toContain("1M"); - }); - }); - - it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => { - await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false, contextWindow: "1m" }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Normal · 1M"); - }); - }); -}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index ea4c8368dee..e0c7eaf3da7 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,21 +1,17 @@ import { - type ClaudeModelOptions, - type CodexModelOptions, - type CursorModelOptions, - type OpenCodeModelOptions, type ProviderKind, - type ProviderModelOptions, + type ProviderOptionDescriptor, + type ProviderOptionSelection, type ScopedThreadRef, type ServerProviderModel, } from "@marcode/contracts"; import { applyClaudePromptEffortPrefix, + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, isClaudeUltrathinkPrompt, - trimOrNull, - getDefaultEffort, - getDefaultContextWindow, - hasContextWindowOption, - resolveEffort, } from "@marcode/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -34,12 +30,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; -type ProviderOptions = ProviderModelOptions[ProviderKind]; -type NamedOption = { - value: string; - label: string; - isDefault?: boolean | undefined; -}; +type ProviderOptions = ReadonlyArray; type TraitsPersistence = | { @@ -54,62 +45,34 @@ type TraitsPersistence = const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; -function getRawEffort( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, -): string | null { - if (provider === "codex") { - return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); - } - if (provider === "cursor") { - return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning); - } - if (provider === "opencode") { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); - } - return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); -} - -function getEffortKey(provider: ProviderKind): string { - if (provider === "codex") return "reasoningEffort"; - if (provider === "cursor") return "reasoning"; - if (provider === "opencode") return "variant"; - return "effort"; -} - -function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); -} - -function resolveNamedOption( - options: ReadonlyArray, - raw: string | null, -): NamedOption | null { - if (raw) { - const matchingOption = options.find((option) => option.value === raw); - if (matchingOption) { - return matchingOption; - } - } - return options.find((option) => option.isDefault) ?? options[0] ?? null; +function replaceDescriptorCurrentValue( + descriptors: ReadonlyArray, + descriptorId: string, + currentValue: string | boolean | undefined, +): ReadonlyArray { + return descriptors.map((descriptor) => + descriptor.id !== descriptorId + ? descriptor + : descriptor.type === "boolean" + ? { + ...descriptor, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + } + : { + ...descriptor, + ...(typeof currentValue === "string" ? { currentValue } : {}), + }, + ); } -function getRawContextWindow( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, +function getDescriptorStringValue( + descriptor: Extract | null, ): string | null { - if (modelOptions && "contextWindow" in modelOptions) { - return trimOrNull(modelOptions.contextWindow); + if (!descriptor) { + return null; } - return null; -} - -function buildNextOptions( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, - patch: Record, -): ProviderOptions { - return { ...(modelOptions as Record | undefined), ...patch } as ProviderOptions; + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : null; } function getSelectedTraits( @@ -121,71 +84,68 @@ function getSelectedTraits( allowPromptInjectedEffort: boolean, ) { const caps = getProviderModelCapabilities(models, model, provider); - const effortLevels = - provider === "opencode" - ? (caps.variantOptions ?? []) - : allowPromptInjectedEffort - ? caps.reasoningEffortLevels - : caps.reasoningEffortLevels.filter( - (option) => !caps.promptInjectedEffortLevels.includes(option.value), - ); - - // Resolve effort from options (provider-specific key) - const rawEffort = getRawEffort(provider, modelOptions); - const effort = - provider === "opencode" - ? (resolveNamedOption(effortLevels, rawEffort)?.value ?? null) - : (resolveEffort(caps, rawEffort) ?? null); - - // Thinking toggle (only for models that support it) - const thinkingEnabled = caps.supportsThinkingToggle - ? modelOptions && "thinking" in modelOptions - ? modelOptions.thinking === true - : null - : null; - - // Fast mode - const fastModeEnabled = - caps.supportsFastMode && - (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; - - // Context window - const contextWindowOptions = caps.contextWindowOptions; - const rawContextWindow = getRawContextWindow(provider, modelOptions); - const defaultContextWindow = getDefaultContextWindow(caps); - const contextWindow = - rawContextWindow && hasContextWindowOption(caps, rawContextWindow) - ? rawContextWindow - : defaultContextWindow; + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelOptions, + }); + const selectDescriptors = descriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "select", + ); + const booleanDescriptors = descriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "boolean", + ); + const primarySelectDescriptor = selectDescriptors[0] ?? null; + const contextWindowDescriptor = + selectDescriptors.find((descriptor) => descriptor.id === "contextWindow") ?? null; + const agentDescriptor = selectDescriptors.find((descriptor) => descriptor.id === "agent") ?? null; + const fastModeDescriptor = + booleanDescriptors.find((descriptor) => descriptor.id === "fastMode") ?? null; + const thinkingDescriptor = + booleanDescriptors.find((descriptor) => descriptor.id === "thinking") ?? null; // Prompt-controlled effort (e.g. ultrathink in prompt text) const ultrathinkPromptControlled = allowPromptInjectedEffort && - caps.promptInjectedEffortLevels.length > 0 && + (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && isClaudeUltrathinkPrompt(prompt); // Check if "ultrathink" appears in the body text (not just our prefix) const ultrathinkInBodyText = ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); - - const agentOptions = caps.agentOptions ?? []; - const selectedAgentOption = - provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null; + const effort = + (ultrathinkPromptControlled + ? "ultrathink" + : getDescriptorStringValue(primarySelectDescriptor)) ?? null; + const thinkingEnabled = + typeof thinkingDescriptor?.currentValue === "boolean" ? thinkingDescriptor.currentValue : null; + const fastModeEnabled = + typeof fastModeDescriptor?.currentValue === "boolean" ? fastModeDescriptor.currentValue : false; + const contextWindow = getDescriptorStringValue(contextWindowDescriptor); + const selectedAgent = getDescriptorStringValue(agentDescriptor); + const selectedAgentLabel = agentDescriptor + ? getProviderOptionCurrentLabel(agentDescriptor) + : null; return { caps, + descriptors, + selectDescriptors, + booleanDescriptors, + primarySelectDescriptor, + contextWindowDescriptor, + agentDescriptor, + fastModeDescriptor, + thinkingDescriptor, effort, - effortLevels, thinkingEnabled, fastModeEnabled, - contextWindowOptions, contextWindow, - defaultContextWindow, ultrathinkPromptControlled, ultrathinkInBodyText, - agentOptions, - selectedAgent: selectedAgentOption?.value ?? null, - selectedAgentLabel: selectedAgentOption?.label ?? null, + selectedAgent, + selectedAgentLabel, }; } @@ -206,11 +166,11 @@ function getTraitsSectionVisibility(input: { input.allowPromptInjectedEffort ?? true, ); - const showEffort = selected.effort !== null; - const showThinking = selected.thinkingEnabled !== null; - const showFastMode = selected.caps.supportsFastMode; - const showContextWindow = selected.contextWindowOptions.length > 1; - const showAgent = selected.agentOptions.length > 0; + const showEffort = selected.primarySelectDescriptor !== null; + const showThinking = selected.thinkingDescriptor !== null; + const showFastMode = selected.fastModeDescriptor !== null; + const showContextWindow = selected.contextWindowDescriptor !== null; + const showAgent = selected.agentDescriptor !== null; return { ...selected, @@ -275,22 +235,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ [model, persistence, provider, setProviderModelOptions], ); const { - caps, - effort, - effortLevels, - thinkingEnabled, - fastModeEnabled, - contextWindowOptions, - contextWindow, - defaultContextWindow, + descriptors, + selectDescriptors, + booleanDescriptors, + primarySelectDescriptor, ultrathinkPromptControlled, - showEffort, - showThinking, - showFastMode, - showContextWindow, ultrathinkInBodyText, - agentOptions, - selectedAgent, hasAnyControls, } = getTraitsSectionVisibility({ provider, @@ -300,53 +250,30 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelOptions, allowPromptInjectedEffort, }); - const defaultEffort = getDefaultEffort(caps); - const showsEffortSection = showEffort; - const showsThinkingSection = !showEffort && showThinking; - const showsFastModeSection = showFastMode; - const showsContextWindowSection = showContextWindow; - const hasSectionsBeforeAgent = - showsEffortSection || showsThinkingSection || showsFastModeSection || showsContextWindowSection; + const updateDescriptors = (nextDescriptors: ReadonlyArray) => { + updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); + }; - const handleEffortChange = useCallback( - (value: string) => { - if (!value) return; - const nextOption = effortLevels.find((option) => option.value === value); - if (!nextOption) return; - if (provider === "opencode") { - updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); - return; - } - if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - onPromptChange(nextPrompt); - return; - } - if (ultrathinkInBodyText) return; - if (ultrathinkPromptControlled) { - const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); - onPromptChange(stripped); - } - const effortKey = getEffortKey(provider); - updateModelOptions( - buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), - ); - }, - [ - ultrathinkPromptControlled, - ultrathinkInBodyText, - modelOptions, - onPromptChange, - updateModelOptions, - effortLevels, - prompt, - caps.promptInjectedEffortLevels, - provider, - ], - ); + const handleSelectChange = ( + descriptor: Extract, + value: string, + ) => { + if (!value) return; + if (descriptor.promptInjectedValues?.includes(value)) { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + onPromptChange(nextPrompt); + return; + } + if (ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id) return; + if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { + const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); + onPromptChange(stripped); + } + updateDescriptors(replaceDescriptorCurrentValue(descriptors, descriptor.id, value)); + }; if (!hasAnyControls) { return null; @@ -354,121 +281,62 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> - {showsEffortSection ? ( - <> + {selectDescriptors.map((descriptor, index) => ( +
+ {index > 0 ? : null}
- {provider === "opencode" ? "Variant" : "Effort"} + {descriptor.label}
- {ultrathinkInBodyText ? ( + {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? (
- Your prompt contains "ultrathink" in the text. Remove it to change effort. + Your prompt contains "ultrathink" in the text. Remove it to change this + option.
) : null} handleSelectChange(descriptor, value)} > - {effortLevels.map((option) => ( + {descriptor.options.map((option) => ( {option.label} - {(provider === "opencode" ? option.isDefault : option.value === defaultEffort) - ? " (default)" - : ""} + {option.isDefault ? " (default)" : ""} ))}
- - ) : showsThinkingSection ? ( - -
Thinking
- { - updateModelOptions( - buildNextOptions(provider, modelOptions, { thinking: value === "on" }), - ); - }} - > - On (default) - Off - -
- ) : null} - {showsFastModeSection ? ( - <> - {showsEffortSection || showsThinkingSection ? : null} - -
Fast Mode
- { - updateModelOptions( - buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), - ); - }} - > - off - on - -
- - ) : null} - {showsContextWindowSection ? ( - <> - {showsEffortSection || showsThinkingSection || showsFastModeSection ? ( - - ) : null} +
+ ))} + {booleanDescriptors.map((descriptor, index) => ( +
+ {index > 0 || selectDescriptors.length > 0 ? : null}
- Context Window + {descriptor.label}
{ - updateModelOptions( - buildNextOptions(provider, modelOptions, { - contextWindow: value, - }), + updateDescriptors( + replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), ); }} > - {contextWindowOptions.map((option) => ( - - {option.label} - {option.value === defaultContextWindow ? " (default)" : ""} - - ))} - -
- - ) : null} - {agentOptions.length > 0 ? ( - <> - {hasSectionsBeforeAgent ? : null} - -
Agent
- { - updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value })); - }} - > - {agentOptions.map((option) => ( - - {option.label} - {option.isDefault ? " (default)" : ""} - - ))} + On + Off
- - ) : null} +
+ ))} ); }); @@ -486,52 +354,15 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const { - caps, - effort, - effortLevels, - thinkingEnabled, - fastModeEnabled, - contextWindowOptions, - contextWindow, - defaultContextWindow, - ultrathinkPromptControlled, - showEffort, - showThinking, - showContextWindow, - } = getTraitsSectionVisibility({ - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - }); - const { selectedAgentLabel } = getSelectedTraits( - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - ); - - const effortLabel = effort - ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) - : null; - const primaryTraitLabel = ultrathinkPromptControlled - ? "Ultrathink" - : effortLabel - ? effortLabel - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`; - const contextWindowLabel = - showContextWindow && contextWindow !== defaultContextWindow - ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) - : null; - const fastOnlyControl = - caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow; + const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + getTraitsSectionVisibility({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + }); if ( !shouldRenderTraitsControls({ provider, @@ -545,27 +376,22 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const selectedTriggerTraits = [ - primaryTraitLabel, - ...(caps.supportsFastMode && - (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null)) - ? [fastModeEnabled ? "Fast" : "Normal"] - : []), - ...(contextWindowLabel ? [contextWindowLabel] : []), - ...(selectedAgentLabel ? [selectedAgentLabel] : []), - ].filter(Boolean); - const triggerLabel = fastOnlyControl - ? fastModeEnabled - ? "Fast" - : "Normal" - : selectedTriggerTraits.length > 0 - ? selectedTriggerTraits.join(" · ") - : caps.supportsFastMode - ? "Normal" - : defaultContextWindow - ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ?? - defaultContextWindow) - : (selectedAgentLabel ?? ""); + const triggerLabel = + descriptors + .map((descriptor) => { + if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { + return "Ultrathink"; + } + if (descriptor.type === "boolean") { + if (descriptor.id === "fastMode") { + return descriptor.currentValue === true ? "Fast" : "Normal"; + } + return `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`; + } + return getProviderOptionCurrentLabel(descriptor); + }) + .filter((label): label is string => typeof label === "string" && label.length > 0) + .join(" · ") || ""; const isCodexStyle = provider === "codex"; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx deleted file mode 100644 index e8fd277d21e..00000000000 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ /dev/null @@ -1,516 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ServerProviderModel } from "@marcode/contracts"; -import { - getComposerProviderControls, - getComposerProviderState, - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./composerProviderRegistry"; - -const CODEX_MODELS: ReadonlyArray = [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const CLAUDE_MODELS: ReadonlyArray = [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray = [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Haiku 4.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -const OPENCODE_MODELS: ReadonlyArray = [ - { - slug: "openai/gpt-5", - name: "GPT-5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - variantOptions: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - ], - agentOptions: [ - { value: "build", label: "Build", isDefault: true }, - { value: "plan", label: "Plan" }, - ], - }, - }, -]; - -describe("getComposerProviderState", () => { - it("returns codex defaults when no codex draft options exist", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: undefined, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - }, - }); - }); - - it("normalizes codex dispatch options while preserving the selected effort", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "low", - modelOptionsForDispatch: { - reasoningEffort: "low", - fastMode: true, - }, - }); - }); - - it("preserves codex fast mode when it is the only active option", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - fastMode: true, - }, - }); - }); - - it("preserves codex default effort explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - prompt: "", - modelOptions: { - codex: { - reasoningEffort: "high", - fastMode: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: { - reasoningEffort: "high", - fastMode: false, - }, - }); - }); - - it("returns Claude defaults for effort-capable models", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: undefined, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "medium", - modelOptionsForDispatch: { - effort: "medium", - }, - }); - }); - - it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "Ultrathink:\nInvestigate this failure", - modelOptions: { - claudeAgent: { - effort: "medium", - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "medium", - modelOptionsForDispatch: { - effort: "medium", - }, - composerFrameClassName: "ultrathink-frame", - composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", - modelPickerIconClassName: "ultrathink-chroma", - }); - }); - - it("drops unsupported Claude effort options for models without effort controls", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "max", - thinking: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: null, - modelOptionsForDispatch: { - thinking: false, - }, - }); - }); - - it("preserves Claude fast mode when it is the only active option", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "medium", - modelOptionsForDispatch: { - effort: "medium", - fastMode: true, - }, - }); - }); - - it("preserves Claude default effort explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: false, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "high", - modelOptionsForDispatch: { - effort: "high", - fastMode: false, - }, - }); - }); - - it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => { - // Regression: normalizeClaudeModelOptionsWithCapabilities used to strip - // fastMode: false, which meant deepMerge could never clear a previous true. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: false, - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); - }); - - it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => { - // Regression: thinking: true (the default) used to be stripped, which - // meant deepMerge could never clear a previous thinking: false. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - thinking: true, - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); - }); - - it("preserves Claude default context window explicitly in dispatch options", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - contextWindow: "200k", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toMatchObject({ - effort: "high", - contextWindow: "200k", - }); - }); - - it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => { - // Regression: the default contextWindow must survive normalization so - // deepMerge can clear an older non-default 1m selection. - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - contextWindow: "200k", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k"); - }); - - it("omits contextWindow when the model does not support it", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, - prompt: "", - modelOptions: { - claudeAgent: { - contextWindow: "1m", - }, - }, - }); - - expect(state.modelOptionsForDispatch).toBeUndefined(); - }); - - it("omits fastMode when the model does not support it", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - prompt: "", - modelOptions: { - claudeAgent: { - effort: "high", - fastMode: true, - }, - }, - }); - - expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); - }); - - it("preserves OpenCode variant and agent options for dispatch", () => { - const state = getComposerProviderState({ - provider: "opencode", - model: "openai/gpt-5", - models: OPENCODE_MODELS, - prompt: "", - modelOptions: { - opencode: { - variant: "medium", - agent: "plan", - }, - }, - }); - - expect(state).toEqual({ - provider: "opencode", - promptEffort: "medium", - modelOptionsForDispatch: { - variant: "medium", - agent: "plan", - }, - }); - }); -}); - -describe("getComposerProviderControls", () => { - it("hides the interaction mode toggle for OpenCode", () => { - expect(getComposerProviderControls("opencode")).toEqual({ - showInteractionModeToggle: false, - }); - }); - - it("keeps the interaction mode toggle for Codex and Claude", () => { - expect(getComposerProviderControls("codex")).toEqual({ - showInteractionModeToggle: true, - }); - expect(getComposerProviderControls("claudeAgent")).toEqual({ - showInteractionModeToggle: true, - }); - }); -}); - -describe("provider traits render guards", () => { - it("returns null for codex traits picker when no thread target is provided", () => { - const content = renderProviderTraitsPicker({ - provider: "codex", - model: "gpt-5.4", - models: CODEX_MODELS, - modelOptions: undefined, - prompt: "", - onPromptChange: () => {}, - }); - - expect(content).toBeNull(); - }); - - it("returns null for claude traits menu content when no thread target is provided", () => { - const content = renderProviderTraitsMenuContent({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - models: CLAUDE_MODELS, - modelOptions: undefined, - prompt: "", - onPromptChange: () => {}, - }); - - expect(content).toBeNull(); - }); -}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx deleted file mode 100644 index f98a9b2f45d..00000000000 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - type ProviderKind, - type ProviderModelOptions, - type ScopedThreadRef, - type ServerProviderModel, -} from "@marcode/contracts"; -import { - isClaudeUltrathinkPrompt, - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, - normalizeProviderModelOptionsWithCapabilities, - resolveEffort, - trimOrNull, -} from "@marcode/shared/model"; -import type { ReactNode } from "react"; - -import type { DraftId } from "../../composerDraftStore"; -import { getProviderModelCapabilities } from "../../providerModels"; -import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; - -export type ComposerProviderStateInput = { - provider: ProviderKind; - model: string; - models: ReadonlyArray; - prompt: string; - modelOptions: ProviderModelOptions | null | undefined; -}; - -export type ComposerProviderState = { - provider: ProviderKind; - promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; - composerFrameClassName?: string; - composerSurfaceClassName?: string; - modelPickerIconClassName?: string; -}; - -type TraitsRenderInput = { - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}; - -export type ComposerProviderControls = { - showInteractionModeToggle: boolean; -}; - -type ProviderRegistryEntry = { - controls: ComposerProviderControls; - getState: (input: ComposerProviderStateInput) => ComposerProviderState; - renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode; - renderTraitsPicker: (input: TraitsRenderInput) => ReactNode; -}; - -function hasComposerTraitsTarget(input: { - threadRef: ScopedThreadRef | undefined; - draftId: DraftId | undefined; -}): boolean { - return input.threadRef !== undefined || input.draftId !== undefined; -} - -function renderTraitsControl( - Component: typeof TraitsMenuContent | typeof TraitsPicker, - provider: ProviderKind, - input: TraitsRenderInput, -): ReactNode { - const { threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = input; - if ( - !hasComposerTraitsTarget({ threadRef, draftId }) || - !shouldRenderTraitsControls({ - provider, - models, - model, - modelOptions, - prompt, - }) - ) { - return null; - } - - return ( - - ); -} - -function getProviderStateFromCapabilities( - input: ComposerProviderStateInput, -): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; - const caps = getProviderModelCapabilities(models, model, provider); - const providerOptions = modelOptions?.[provider]; - const rawEffort = providerOptions - ? "effort" in providerOptions - ? providerOptions.effort - : "reasoningEffort" in providerOptions - ? providerOptions.reasoningEffort - : "reasoning" in providerOptions - ? providerOptions.reasoning - : "variant" in providerOptions - ? providerOptions.variant - : null - : null; - const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( - provider, - caps, - providerOptions, - ); - const promptEffort = - provider === "opencode" - ? (trimOrNull( - normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, - ) ?? null) - : (resolveEffort(caps, rawEffort) ?? null); - const ultrathinkActive = - caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); - - return { - provider, - promptEffort, - modelOptionsForDispatch: normalizedOptions, - ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), - ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } - : {}), - ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), - }; -} - -const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = { - showInteractionModeToggle: true, -}; - -function createProviderRegistryEntry( - provider: ProviderKind, - controls?: Partial, -): ProviderRegistryEntry { - return { - controls: { - ...DEFAULT_PROVIDER_CONTROLS, - ...controls, - }, - getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: (input) => renderTraitsControl(TraitsMenuContent, provider, input), - renderTraitsPicker: (input) => renderTraitsControl(TraitsPicker, provider, input), - }; -} - -const composerProviderRegistry: Record = { - codex: createProviderRegistryEntry("codex"), - claudeAgent: createProviderRegistryEntry("claudeAgent"), - cursor: createProviderRegistryEntry("cursor"), - opencode: createProviderRegistryEntry("opencode", { - showInteractionModeToggle: false, - }), -}; - -export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - return composerProviderRegistry[input.provider].getState(input); -} - -export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls { - return composerProviderRegistry[provider].controls; -} - -export function renderProviderTraitsMenuContent(input: { - provider: ProviderKind; - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}): ReactNode { - return composerProviderRegistry[input.provider].renderTraitsMenuContent({ - ...(input.threadRef ? { threadRef: input.threadRef } : {}), - ...(input.draftId ? { draftId: input.draftId } : {}), - model: input.model, - models: input.models, - modelOptions: input.modelOptions, - prompt: input.prompt, - onPromptChange: input.onPromptChange, - }); -} - -export function renderProviderTraitsPicker(input: { - provider: ProviderKind; - threadRef?: ScopedThreadRef; - draftId?: DraftId; - model: string; - models: ReadonlyArray; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; -}): ReactNode { - return composerProviderRegistry[input.provider].renderTraitsPicker({ - ...(input.threadRef ? { threadRef: input.threadRef } : {}), - ...(input.draftId ? { draftId: input.draftId } : {}), - model: input.model, - models: input.models, - modelOptions: input.modelOptions, - prompt: input.prompt, - onPromptChange: input.onPromptChange, - }); -} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx new file mode 100644 index 00000000000..acffa69a40f --- /dev/null +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import type { + ProviderKind, + ProviderOptionDescriptor, + ProviderOptionSelection, + ServerProviderModel, +} from "@marcode/contracts"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderState"; + +// Everything in composerProviderState is now data-driven by the model's +// optionDescriptors, so these tests use a single synthetic provider/model and +// vary only the descriptor shape per scenario. + +const PROVIDER: ProviderKind = "codex"; +const MODEL = "test-model"; + +function selectDescriptor( + id: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, + promptInjectedValues?: ReadonlyArray, +): Extract { + const defaultId = options.find((option) => option.isDefault)?.id; + return { + id, + label: id, + type: "select", + options: [...options], + ...(defaultId ? { currentValue: defaultId } : {}), + ...(promptInjectedValues && promptInjectedValues.length > 0 + ? { promptInjectedValues: [...promptInjectedValues] } + : {}), + }; +} + +function booleanDescriptor(id: string): Extract { + return { id, label: id, type: "boolean" }; +} + +function modelWith( + descriptors: ReadonlyArray, +): ReadonlyArray { + return [ + { slug: MODEL, name: MODEL, isCustom: false, capabilities: { optionDescriptors: descriptors } }, + ]; +} + +function selections( + ...entries: Array<[string, string | boolean]> +): ReadonlyArray { + return entries.map(([id, value]) => ({ id, value })); +} + +const ULTRATHINK_FRAME_CLASSES = { + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", +} as const; + +describe("getComposerProviderState", () => { + it("returns descriptor defaults when no selections are provided", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + ]), + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "high", + modelOptionsForDispatch: selections(["effort", "high"]), + }); + }); + + it("lets selections override defaults and propagates them through dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode"), + ]), + prompt: "", + modelOptions: selections(["effort", "low"], ["fastMode", true]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "low", + modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), + }); + }); + + it("preserves selections that match defaults so deepMerge can overwrite prior state", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + booleanDescriptor("fastMode"), + ]), + prompt: "", + modelOptions: selections(["effort", "high"], ["fastMode", false]), + }); + + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["fastMode", false]), + ); + }); + + it("drops selections for descriptors the model does not declare", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([booleanDescriptor("thinking")]), + prompt: "", + modelOptions: selections(["effort", "max"], ["thinking", false]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["thinking", false]), + }); + }); + + it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + selectDescriptor("contextWindow", [ + { id: "200k", label: "200k", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ]), + prompt: "", + modelOptions: selections(["agent", "plan"]), + }); + + expect(state.promptEffort).toBe("high"); + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]), + ); + }); + + it("returns undefined dispatch options when the model declares no descriptors", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([]), + prompt: "", + modelOptions: selections(["anything", "value"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor( + "effort", + [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + ["ultrathink"], + ), + ]), + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: selections(["effort", "medium"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "medium", + modelOptionsForDispatch: selections(["effort", "medium"]), + ...ULTRATHINK_FRAME_CLASSES, + }); + }); + + it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]), + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: undefined, + }); + + expect(state).not.toHaveProperty("composerFrameClassName"); + expect(state).not.toHaveProperty("composerSurfaceClassName"); + expect(state).not.toHaveProperty("modelPickerIconClassName"); + }); +}); + +describe("provider traits render guards", () => { + it("returns null when no thread target is provided", () => { + const models = modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const args = { + provider: PROVIDER, + model: MODEL, + models, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }; + + expect(renderProviderTraitsPicker(args)).toBeNull(); + expect(renderProviderTraitsMenuContent(args)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx new file mode 100644 index 00000000000..c0ae0f94e73 --- /dev/null +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -0,0 +1,108 @@ +import { + type ProviderKind, + type ProviderOptionSelection, + type ScopedThreadRef, + type ServerProviderModel, +} from "@marcode/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + isClaudeUltrathinkPrompt, +} from "@marcode/shared/model"; +import type { ReactNode } from "react"; + +import type { DraftId } from "../../composerDraftStore"; +import { getProviderModelCapabilities } from "../../providerModels"; +import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; + +export type ComposerProviderStateInput = { + provider: ProviderKind; + model: string; + models: ReadonlyArray; + prompt: string; + modelOptions: ReadonlyArray | null | undefined; +}; + +export type ComposerProviderState = { + provider: ProviderKind; + promptEffort: string | null; + modelOptionsForDispatch: ReadonlyArray | undefined; + composerFrameClassName?: string; + composerSurfaceClassName?: string; + modelPickerIconClassName?: string; +}; + +type TraitsRenderInput = { + provider: ProviderKind; + threadRef?: ScopedThreadRef; + draftId?: DraftId; + model: string; + models: ReadonlyArray; + modelOptions: ReadonlyArray | undefined; + prompt: string; + onPromptChange: (prompt: string) => void; +}; + +export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { + const { provider, model, models, prompt, modelOptions } = input; + const caps = getProviderModelCapabilities(models, model, provider); + const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); + const primarySelectDescriptor = descriptors.find( + (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => + descriptor.type === "select", + ); + const primaryValue = getProviderOptionCurrentValue(primarySelectDescriptor ?? null); + const promptEffort = typeof primaryValue === "string" ? primaryValue : null; + const ultrathinkActive = + (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && + isClaudeUltrathinkPrompt(prompt); + + return { + provider, + promptEffort, + modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), + ...(ultrathinkActive + ? { + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", + } + : {}), + }; +} + +function renderTraitsControl( + Component: typeof TraitsMenuContent | typeof TraitsPicker, + input: TraitsRenderInput, +): ReactNode { + const { provider, threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = + input; + const hasTarget = threadRef !== undefined || draftId !== undefined; + if ( + !hasTarget || + !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + ) { + return null; + } + return ( + + ); +} + +export function renderProviderTraitsMenuContent(input: TraitsRenderInput): ReactNode { + return renderTraitsControl(TraitsMenuContent, input); +} + +export function renderProviderTraitsPicker(input: TraitsRenderInput): ReactNode { + return renderTraitsControl(TraitsPicker, input); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 47f28f332df..f8f7aee2b89 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1883,11 +1883,21 @@ export function GeneralSettingsPanel() { {providerCard.models.map((model) => { const caps = model.capabilities; const capLabels: string[] = []; - if (caps?.supportsFastMode) capLabels.push("Fast mode"); - if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + const descriptors = caps?.optionDescriptors ?? []; + if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { + capLabels.push("Fast mode"); + } + if (descriptors.some((descriptor) => descriptor.id === "thinking")) { + capLabels.push("Thinking"); + } if ( - caps?.reasoningEffortLevels && - caps.reasoningEffortLevels.length > 0 + descriptors.some( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "effort" || + descriptor.id === "reasoningEffort" || + descriptor.id === "reasoning"), + ) ) { capLabels.push("Reasoning"); } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 1e31dc2caf1..46427212f9f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -10,8 +10,25 @@ import { ProjectId, ThreadId, type ModelSelection, - type ProviderModelOptions, + type ProviderKind, + type ProviderOptionSelection, } from "@marcode/contracts"; + +type ProviderOptionSelectionBag = ReadonlyArray; +type ProviderOptionSelectionsByProvider = Partial>; + +function toSelections( + options: Record | undefined, +): ReadonlyArray { + const result: Array = []; + if (!options) return result; + for (const [id, value] of Object.entries(options)) { + if (typeof value === "string" || typeof value === "boolean") { + result.push({ id, value }); + } + } + return result; +} import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -93,17 +110,26 @@ function resetComposerDraftStore() { function modelSelection( provider: "codex" | "claudeAgent" | "cursor", model: string, - options?: ModelSelection["options"], + options?: Record, ): ModelSelection { + const asSelections = options ? toSelections(options) : undefined; return { provider, model, - ...(options ? { options } : {}), + ...(asSelections && asSelections.length > 0 ? { options: asSelections } : {}), } as ModelSelection; } -function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { - return options; +function providerModelOptions( + options: Partial>>, +): ProviderOptionSelectionsByProvider { + const result: ProviderOptionSelectionsByProvider = {}; + for (const [provider, bag] of Object.entries(options) as Array< + [ProviderKind, Record] + >) { + result[provider] = toSelections(bag); + } + return result; } const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -944,9 +970,9 @@ describe("composerDraftStore modelSelection", () => { store.setProviderModelOptions( threadRef, "claudeAgent", - { + toSelections({ thinking: false, - }, + }), { persistSticky: true }, ); @@ -972,9 +998,13 @@ describe("composerDraftStore modelSelection", () => { }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { - thinking: true, - }); + store.setProviderModelOptions( + threadRef, + "claudeAgent", + toSelections({ + thinking: true, + }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -989,10 +1019,14 @@ describe("composerDraftStore modelSelection", () => { store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); - store.setProviderModelOptions(threadRef, "codex", { - reasoningEffort: "high", - fastMode: false, - }); + store.setProviderModelOptions( + threadRef, + "codex", + toSelections({ + reasoningEffort: "high", + fastMode: false, + }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { @@ -1014,11 +1048,15 @@ describe("composerDraftStore modelSelection", () => { }), ); - store.setProviderModelOptions(threadRef, "cursor", { - reasoning: "medium", - fastMode: false, - thinking: true, - }); + store.setProviderModelOptions( + threadRef, + "cursor", + toSelections({ + reasoning: "medium", + fastMode: false, + thinking: true, + }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( modelSelection("cursor", "claude-opus-4-6", { @@ -1035,9 +1073,9 @@ describe("composerDraftStore modelSelection", () => { store.setProviderModelOptions( threadRef, "cursor", - { + toSelections({ reasoning: "high", - }, + }), { model: "gpt-5.4", persistSticky: true, @@ -1067,9 +1105,13 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { - thinking: false, - }); + store.setProviderModelOptions( + threadRef, + "claudeAgent", + toSelections({ + thinking: false, + }), + ); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { @@ -1097,8 +1139,12 @@ describe("composerDraftStore modelSelection", () => { store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual( + toSelections({ reasoningEffort: "xhigh" }), + ); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( + toSelections({ effort: "max" }), + ); }); it("preserves other provider options when switching the active model selection", () => { @@ -1118,7 +1164,7 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual(toSelections({ fastMode: true })); expect(draft?.activeProvider).toBe("claudeAgent"); }); @@ -1130,9 +1176,9 @@ describe("composerDraftStore modelSelection", () => { store.setProviderModelOptions( threadRef, "codex", - { + toSelections({ fastMode: true, - }, + }), { persistSticky: true }, ); @@ -1157,9 +1203,9 @@ describe("composerDraftStore modelSelection", () => { store.setProviderModelOptions( threadRef, "claudeAgent", - { + toSelections({ thinking: false, - }, + }), { persistSticky: false }, ); @@ -1279,12 +1325,14 @@ describe("composerDraftStore provider-scoped option updates", () => { reasoningEffort: "medium", }), ); - store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); + store.setProviderModelOptions(threadRef, "claudeAgent", toSelections({ effort: "max" })); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual( + toSelections({ effort: "max" }), + ); expect(draft?.activeProvider).toBe("codex"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 242e86fdb50..58703d8f391 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,16 +1,11 @@ import { - CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - type CursorModelOptions, - type CursorReasoningOption, - ClaudeAgentEffort, - CodexReasoningEffort, type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, - ProviderModelOptions, + ProviderOptionSelection, RuntimeMode, type ServerProvider, type ScopedProjectRef, @@ -108,23 +103,30 @@ const PersistedComposerThreadDraftState = Schema.Struct({ }); type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; -const LegacyCodexFields = Schema.Struct({ - effort: Schema.optionalKey(CodexReasoningEffort), - codexFastMode: Schema.optionalKey(Schema.Boolean), - serviceTier: Schema.optionalKey(Schema.String), -}); -type LegacyCodexFields = typeof LegacyCodexFields.Type; +/** + * Per-provider record of generic option selections. Used as a transient + * representation when migrating legacy v2 storage payloads and when + * deriving per-provider option bundles for downstream consumers. + */ +type ProviderOptionSelectionsByProvider = Partial< + Record> +>; -const LegacyThreadModelFields = Schema.Struct({ - provider: Schema.optionalKey(ProviderKind), - model: Schema.optionalKey(Schema.String), - modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), -}); -type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; +type LegacyCodexFields = { + effort?: unknown; + codexFastMode?: unknown; + serviceTier?: unknown; +}; + +type LegacyThreadModelFields = { + provider?: unknown; + model?: unknown; + modelOptions?: unknown; +}; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; - modelOptions?: ProviderModelOptions | null; + modelOptions?: unknown; }; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & @@ -132,16 +134,15 @@ type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState LegacyThreadModelFields & LegacyV2ThreadDraftFields; -const LegacyStickyModelFields = Schema.Struct({ - stickyProvider: Schema.optionalKey(ProviderKind), - stickyModel: Schema.optionalKey(Schema.String), - stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), -}); -type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; +type LegacyStickyModelFields = { + stickyProvider?: unknown; + stickyModel?: unknown; + stickyModelOptions?: unknown; +}; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; - stickyModelOptions?: ProviderModelOptions | null; + stickyModelOptions?: unknown; projectDraftThreadIdByProjectId?: Record | null; draftsByThreadId?: Record | null; draftThreadsByThreadId?: Record | null; @@ -340,13 +341,13 @@ interface ComposerDraftStoreState { ) => void; setModelOptions: ( threadRef: ComposerThreadTarget, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: ProviderOptionSelectionsByProvider | null | undefined, ) => void; applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( threadRef: ComposerThreadTarget, provider: ProviderKind, - nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, + nextProviderOptions: ReadonlyArray | null | undefined, options?: { model?: string | null | undefined; persistSticky?: boolean; @@ -390,7 +391,7 @@ interface ComposerDraftStoreState { export interface EffectiveComposerModelState { selectedModel: string; - modelOptions: ProviderModelOptions | null; + modelOptions: ProviderOptionSelectionsByProvider | null; } interface ComposerDraftModelState { @@ -400,27 +401,25 @@ interface ComposerDraftModelState { function providerModelOptionsFromSelection( modelSelection: ModelSelection | null | undefined, -): ProviderModelOptions | null { - if (!modelSelection?.options) { +): ProviderOptionSelectionsByProvider | null { + if (!modelSelection?.options || modelSelection.options.length === 0) { return null; } - return { - [modelSelection.provider]: modelSelection.options, - }; + return { [modelSelection.provider]: modelSelection.options } as ProviderOptionSelectionsByProvider; } function modelSelectionByProviderToOptions( map: Partial> | null | undefined, -): ProviderModelOptions | null { +): ProviderOptionSelectionsByProvider | null { if (!map) return null; - const result: Record = {}; + const result: ProviderOptionSelectionsByProvider = {}; for (const [provider, selection] of Object.entries(map)) { - if (selection?.options) { - result[provider] = selection.options; + if (selection?.options && selection.options.length > 0) { + result[provider as ProviderKind] = selection.options; } } - return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null; + return Object.keys(result).length > 0 ? result : null; } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ @@ -555,149 +554,98 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { : null; } +/** + * Coerce an arbitrary value into a list of `ProviderOptionSelection` entries. + * + * Two shapes are supported: + * 1. The canonical array shape `[{ id, value }, ...]` emitted by the + * upstream option-array refactor. + * 2. A legacy object shape `{ reasoningEffort: "high", fastMode: true }` + * which we migrate by promoting each key/value pair to an entry. + * + * Values other than strings/booleans are dropped. Empty results return + * `undefined` so callers can omit the provider entry entirely. This + * function does not validate ids against the provider's descriptor set — + * that's the responsibility of the capability layer, which is the source + * of truth for which option ids are meaningful for a given provider/model. + * Anything outside the descriptor list is harmless trailing data and will + * simply be ignored downstream. + */ +function coerceProviderOptionSelections( + value: unknown, +): ReadonlyArray | undefined { + if (Array.isArray(value)) { + const out: ProviderOptionSelection[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const id = record.id; + const optionValue = record.value; + if (typeof id !== "string" || id.length === 0) continue; + if (typeof optionValue === "string" || typeof optionValue === "boolean") { + out.push({ id, value: optionValue }); + } + } + return out.length > 0 ? out : undefined; + } + if (value && typeof value === "object") { + const record = value as Record; + const out: ProviderOptionSelection[] = []; + for (const [id, raw] of Object.entries(record)) { + if (typeof raw === "string" || typeof raw === "boolean") { + out.push({ id, value: raw }); + } + } + return out.length > 0 ? out : undefined; + } + return undefined; +} + +/** + * Normalize a per-provider options bag from either the v3 or legacy v2 shape. + * + * `provider` and `legacy` parameters are migration-only inputs used to + * recover legacy codex fields (effort/codexFastMode/serviceTier) that lived + * directly on the draft instead of inside `modelOptions.codex`. + */ function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, legacy?: LegacyCodexFields, -): ProviderModelOptions | null { +): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; - const codexCandidate = - candidate?.codex && typeof candidate.codex === "object" - ? (candidate.codex as Record) - : null; - const claudeCandidate = - candidate?.claudeAgent && typeof candidate.claudeAgent === "object" - ? (candidate.claudeAgent as Record) - : null; - const cursorCandidate = - candidate?.cursor && typeof candidate.cursor === "object" - ? (candidate.cursor as Record) - : null; - const openCodeCandidate = - candidate?.opencode && typeof candidate.opencode === "object" - ? (candidate.opencode as Record) - : null; - - const isCodexReasoningEffort = Schema.is(CodexReasoningEffort); - const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort); - - const codexReasoningEffort = isCodexReasoningEffort(codexCandidate?.reasoningEffort) - ? codexCandidate.reasoningEffort - : provider === "codex" - ? isCodexReasoningEffort(legacy?.effort) - ? legacy.effort - : undefined - : undefined; - const codexFastMode = - codexCandidate?.fastMode === true - ? true - : codexCandidate?.fastMode === false - ? false - : (provider === "codex" && legacy?.codexFastMode === true) || - (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") - ? true - : undefined; - const codex = - codexReasoningEffort !== undefined || codexFastMode !== undefined - ? { - ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), - ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), - } - : undefined; - - const claudeThinking = - claudeCandidate?.thinking === true - ? true - : claudeCandidate?.thinking === false - ? false - : undefined; - const claudeEffort = isClaudeAgentEffort(claudeCandidate?.effort) - ? claudeCandidate.effort - : undefined; - const claudeFastMode = - claudeCandidate?.fastMode === true - ? true - : claudeCandidate?.fastMode === false - ? false - : undefined; - const claudeContextWindow = - typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0 - ? claudeCandidate.contextWindow - : undefined; - const claude = - claudeThinking !== undefined || - claudeEffort !== undefined || - claudeFastMode !== undefined || - claudeContextWindow !== undefined - ? { - ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), - ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), - ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), - ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), - } - : undefined; - - const cursorReasoningRaw = cursorCandidate?.reasoning; - const cursorReasoning: CursorReasoningOption | undefined = - typeof cursorReasoningRaw === "string" && - (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) - ? (cursorReasoningRaw as CursorReasoningOption) - : undefined; - const cursorFastMode = - cursorCandidate?.fastMode === true - ? true - : cursorCandidate?.fastMode === false - ? false - : undefined; - const cursorThinking = - cursorCandidate?.thinking === true - ? true - : cursorCandidate?.thinking === false - ? false - : undefined; - const cursorContextWindow = - typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 - ? cursorCandidate.contextWindow - : undefined; - - const cursor: CursorModelOptions | undefined = - cursorCandidate !== null - ? (() => { - const nextCursor = { - ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), - ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), - ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), - ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), - } satisfies CursorModelOptions; - return Object.keys(nextCursor).length > 0 ? nextCursor : undefined; - })() - : undefined; - - const openCodeVariant = - typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 - ? openCodeCandidate.variant - : undefined; - const openCodeAgent = - typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 - ? openCodeCandidate.agent - : undefined; - const opencode = - openCodeVariant !== undefined || openCodeAgent !== undefined - ? { - ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), - ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), - } - : undefined; + const result: Partial>> = {}; + for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + const selections = coerceProviderOptionSelections(candidate?.[providerKey]); + if (selections) { + result[providerKey] = selections; + } + } - if (!codex && !claude && cursor === undefined && !opencode) { - return null; + // Recover legacy codex fields that lived outside modelOptions. + if (provider === "codex" && legacy) { + const codexExtras: ProviderOptionSelection[] = []; + if (typeof legacy.effort === "string" && legacy.effort.length > 0) { + codexExtras.push({ id: "reasoningEffort", value: legacy.effort }); + } + const fastMode = + legacy.codexFastMode === true || + (typeof legacy.serviceTier === "string" && legacy.serviceTier === "fast"); + if (fastMode) { + codexExtras.push({ id: "fastMode", value: true }); + } + if (codexExtras.length > 0) { + const existing = result.codex ?? []; + const existingIds = new Set(existing.map((entry) => entry.id)); + const merged = [...existing]; + for (const extra of codexExtras) { + if (!existingIds.has(extra.id)) merged.push(extra); + } + result.codex = merged; + } } - return { - ...(codex ? { codex } : {}), - ...(claude ? { claudeAgent: claude } : {}), - ...(cursor !== undefined ? { cursor } : {}), - ...(opencode ? { opencode } : {}), - }; + + return Object.keys(result).length > 0 ? result : null; } function normalizeModelSelection( @@ -722,6 +670,10 @@ function normalizeModelSelection( if (!model) { return null; } + if (Array.isArray(candidate?.options)) { + const selections = coerceProviderOptionSelections(candidate.options); + return createModelSelection(provider, model, selections); + } const modelOptions = normalizeProviderModelOptions( candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, provider, @@ -735,7 +687,7 @@ function normalizeModelSelection( function legacySyncModelSelectionOptions( modelSelection: ModelSelection | null, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: ProviderOptionSelectionsByProvider | null | undefined, ): ModelSelection | null { if (modelSelection === null) { return null; @@ -746,9 +698,9 @@ function legacySyncModelSelectionOptions( function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, - currentModelOptions: ProviderModelOptions | null | undefined, -): ProviderModelOptions | null { - if (modelSelection?.options === undefined) { + currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, +): ProviderOptionSelectionsByProvider | null { + if (!modelSelection?.options || modelSelection.options.length === 0) { return normalizeProviderModelOptions(currentModelOptions); } return legacyReplaceProviderModelOptions( @@ -759,35 +711,31 @@ function legacyMergeModelSelectionIntoProviderModelOptions( } function legacyReplaceProviderModelOptions( - currentModelOptions: ProviderModelOptions | null | undefined, + currentModelOptions: ProviderOptionSelectionsByProvider | null | undefined, provider: ProviderKind, - nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, -): ProviderModelOptions | null { + nextProviderOptions: ReadonlyArray | null | undefined, +): ProviderOptionSelectionsByProvider | null { const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = currentModelOptions ?? {}; - const normalizedNextProviderOptions = normalizeProviderModelOptions( - { [provider]: nextProviderOptions }, - provider, - ); - - return normalizeProviderModelOptions({ - ...otherProviderModelOptions, - ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}), - }); + const merged: ProviderOptionSelectionsByProvider = { ...otherProviderModelOptions }; + if (nextProviderOptions && nextProviderOptions.length > 0) { + merged[provider] = nextProviderOptions; + } + return Object.keys(merged).length > 0 ? merged : null; } // ── New helpers for the consolidated representation ──────────────────── function legacyToModelSelectionByProvider( modelSelection: ModelSelection | null, - modelOptions: ProviderModelOptions | null | undefined, + modelOptions: ProviderOptionSelectionsByProvider | null | undefined, ): Partial> { const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { const options = modelOptions[provider]; - if (options && Object.keys(options).length > 0) { + if (options && options.length > 0) { result[provider] = createModelSelection( provider, modelSelection?.provider === provider @@ -1487,14 +1435,15 @@ function normalizePersistedDraftsByThreadId( ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId }) : threadKeyOrId; })(); - nextDraftsByThreadKey[normalizedThreadKey] = { + const persistedEntry = { prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), - }; + } as DeepMutable; + nextDraftsByThreadKey[normalizedThreadKey] = persistedEntry; } return nextDraftsByThreadKey; @@ -1574,7 +1523,7 @@ function partializeComposerDraftStoreState( ) { continue; } - const persistedDraft: DeepMutable = { + const persistedDraft = { prompt: draft.prompt, attachments: draft.persistedAttachments, ...(draft.terminalContexts.length > 0 @@ -1598,7 +1547,7 @@ function partializeComposerDraftStoreState( : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), - }; + } as DeepMutable; persistedDraftsByThreadKey[threadKey] = persistedDraft; } return { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a0a43c82451..38c0ad729df 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -230,7 +230,10 @@ export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record; } if (typeof legacySettings.codexBinaryPath === "string") { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index d8ca3dd6f8b..5f4c0b5d6ad 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -9,7 +9,7 @@ import { normalizeModelSlug, resolveSelectableModel, } from "@marcode/shared/model"; -import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { getComposerProviderState } from "./components/chat/composerProviderState"; import { UnifiedSettings } from "@marcode/contracts/settings"; import { getDefaultServerModel, @@ -222,9 +222,7 @@ export function resolveAppModelSelectionState( model, models: getProviderModels(providers, provider), prompt: "", - modelOptions: { - [provider]: provider === selection.provider ? selection.options : undefined, - }, + modelOptions: provider === selection.provider ? selection.options : undefined, }); return createModelSelection(provider, model, modelOptionsForDispatch); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 639dfe65e19..103351c5e01 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,26 +1,24 @@ import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_KIND, - type CursorModelOptions, type ModelCapabilities, type ProviderKind, type ServerProvider, type ServerProviderModel, } from "@marcode/contracts"; -import { - hasEffortLevel, - normalizeModelSlug, - resolveContextWindow, - trimOrNull, -} from "@marcode/shared/model"; +import { createModelCapabilities, normalizeModelSlug } from "@marcode/shared/model"; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); -const EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; +export function formatProviderKindLabel(provider: ProviderKind): string { + return provider + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} export function getProviderModels( providers: ReadonlyArray, @@ -36,6 +34,21 @@ export function getProviderSnapshot( return providers.find((candidate) => candidate.provider === provider); } +export function getProviderDisplayName( + providers: ReadonlyArray, + provider: ProviderKind, +): string { + const snapshot = getProviderSnapshot(providers, provider); + return snapshot?.displayName?.trim() || formatProviderKindLabel(provider); +} + +export function getProviderInteractionModeToggle( + providers: ReadonlyArray, + provider: ProviderKind, +): boolean { + return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; +} + export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderKind, @@ -77,30 +90,3 @@ export function getDefaultServerModel( DEFAULT_MODEL_BY_PROVIDER[provider] ); } - -export function normalizeCursorModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CursorModelOptions | null | undefined, -): CursorModelOptions | undefined { - const reasoning = trimOrNull(modelOptions?.reasoning); - const reasoningValue = - reasoning && hasEffortLevel(caps, reasoning) - ? (reasoning as CursorModelOptions["reasoning"]) - : undefined; - const fastMode = - caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" - ? modelOptions.fastMode - : undefined; - const thinking = - caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" - ? modelOptions.thinking - : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: CursorModelOptions = { - ...(reasoningValue ? { reasoning: reasoningValue } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), - ...(thinking !== undefined ? { thinking } : {}), - ...(contextWindow ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} From c1bce4fb7eb927fc3932e9dd8463a50c0cc696ed Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 16:56:54 +0300 Subject: [PATCH 6/7] style: apply oxfmt formatter to Commit E files --- .../chat/CompactComposerControlsMenu.browser.tsx | 4 +--- apps/web/src/composerDraftStore.test.ts | 4 +++- apps/web/src/composerDraftStore.ts | 4 +++- apps/web/src/hooks/useSettings.ts | 7 +++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 242e432bed5..56b2fac937e 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -84,9 +84,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str name: "Haiku 4.5", isCustom: false, capabilities: { - optionDescriptors: [ - { id: "thinking", label: "Thinking", type: "boolean" as const }, - ], + optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" as const }], }, }, { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 46427212f9f..a31530cd02b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1164,7 +1164,9 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual(toSelections({ fastMode: true })); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual( + toSelections({ fastMode: true }), + ); expect(draft?.activeProvider).toBe("claudeAgent"); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 58703d8f391..69a471156bc 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -406,7 +406,9 @@ function providerModelOptionsFromSelection( return null; } - return { [modelSelection.provider]: modelSelection.options } as ProviderOptionSelectionsByProvider; + return { + [modelSelection.provider]: modelSelection.options, + } as ProviderOptionSelectionsByProvider; } function modelSelectionByProviderToOptions( diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 38c0ad729df..9feb9917118 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -230,10 +230,9 @@ export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record; + patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection as NonNullable< + (typeof patch)["textGenerationModelSelection"] + >; } if (typeof legacySettings.codexBinaryPath === "string") { From 9bb54da84ee6af10ee7c93e31d27fd2c42650c7e Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 17:09:27 +0300 Subject: [PATCH 7/7] fix(web): align browser tests with upstream TraitsPicker + option-array shape Three CompactComposerControlsMenu.browser.tsx assertions and one ChatView.browser.tsx fixture were still using MarCode's pre-port shape and wording: - "Fast mode" -> "Fast Mode" in Opus fixture to match ClaudeProvider label - `toContain("off")`/`toContain("on")` -> `"On"`/`"Off"` (upstream render casing) - `"On (default)"` -> `"On"` for Haiku thinking (upstream's BoolTrait doesn't annotate defaults like the old MarCode picker did) - `"Remove it to change effort."` -> `"Remove it to change this option."` (upstream's generic descriptor-driven wording) - ChatView.browser.tsx "prefers draft state" expectation switched from legacy object `options` to the canonical `[{id, value}]` array shape (was the only remaining missed retrofit) All 146 apps/web browser tests pass; fmt:check clean; typecheck all 10 packages successful. --- apps/web/src/components/ChatView.browser.tsx | 8 ++++---- .../chat/CompactComposerControlsMenu.browser.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 23d9a88b8a3..5b5c131e622 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4193,10 +4193,10 @@ describe("ChatView timeline estimator parity (full app)", () => { codex: { provider: "codex", model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, + options: [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ], }, }, activeProvider: "codex", diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 56b2fac937e..3bac9ab0990 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -75,7 +75,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], promptInjectedValues: ["ultrathink"], }, - { id: "fastMode", label: "Fast mode", type: "boolean" as const }, + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, ], }, }, @@ -125,7 +125,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str { id: "high", label: "High", isDefault: true }, ], }, - { id: "fastMode", label: "Fast mode", type: "boolean" as const }, + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, ], }, }, @@ -185,8 +185,8 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); + expect(text).toContain("On"); + expect(text).toContain("Off"); }); }); @@ -233,7 +233,7 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); + expect(text).toContain("On"); expect(text).toContain("Off"); }); }); @@ -272,7 +272,7 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', + 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', ); }); });