Skip to content

Refactor provider model selections to option arrays#2246

Merged
juliusmarminge merged 10 commits intomainfrom
t3code/provider-array-refactor
Apr 23, 2026
Merged

Refactor provider model selections to option arrays#2246
juliusmarminge merged 10 commits intomainfrom
t3code/provider-array-refactor

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 20, 2026

Summary

  • Reworked provider model selection from ad hoc option objects to ordered option arrays backed by shared descriptors.
  • Updated Claude, Codex, Cursor, and OpenCode provider flows to resolve model options through the new selection helpers.
  • Centralized built-in provider capabilities and selection snapshots in shared model utilities and contracts.
  • Adjusted web composer, draft state, and provider picker logic to match the new selection shape.
  • Refreshed tests across server, shared, contracts, and web layers to cover the array-based model selection behavior.

Testing

  • Not run (changes were not validated in this environment).
  • Expected verification: bun fmt
  • Expected verification: bun lint
  • Expected verification: bun typecheck
  • Expected verification: bun run test

Note

Medium Risk
Broad refactor across provider adapters, text generation, and web composer plus a DB migration that rewrites persisted modelSelection.options; mistakes could break provider routing or silently drop/ignore option values for existing users.

Overview
Refactors provider/model configuration so modelSelection.options is now a generic ordered array of { id, value } selections, replacing provider-specific option objects across server providers (Claude/Codex/Cursor/OpenCode), orchestration routing, and web composer state.

Provider capability reporting is reshaped around shared option descriptors (via createModelCapabilities/descriptor builders), and provider snapshots now include presentation metadata (displayName, optional badgeLabel, and showInteractionModeToggle).

Adds migration 026_CanonicalizeModelSelectionOptions to convert persisted legacy object-shaped options in projections and orchestration events into the new array format (dropping non-boolean/non-nonempty-string values), and updates tests to use createModelSelection and new descriptor semantics.

Reviewed by Cursor Bugbot for commit bf23358. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Refactor provider model selections from typed option objects to generic ProviderOptionSelection arrays

  • Replaces provider-specific options schemas (CodexModelOptions, ClaudeModelOptions, CursorModelOptions, etc.) with a unified ProviderOptionSelection array ({id, value}[]) across contracts, server, and web layers.
  • Adds ProviderOptionDescriptor types (select/boolean) to ModelCapabilities, and introduces helpers like getProviderOptionDescriptors, buildProviderOptionSelectionsFromDescriptors, and createModelCapabilities to drive UI and adapter logic from descriptors instead of hard-coded provider checks.
  • Adds migration 026_CanonicalizeModelSelectionOptions to convert legacy object-shaped options in DB JSON fields to the canonical array form.
  • Removes legacy capability fields (reasoningEffortLevels, supportsFastMode, supportsThinkingToggle, etc.) and provider-specific normalizers; TraitsPicker and ChatComposer now render and dispatch options driven entirely by descriptors.
  • Provider snapshots now include displayName, badgeLabel, and showInteractionModeToggle presentation fields surfaced from server to UI.
  • Risk: RoutingTextGeneration no longer falls back to Codex for unrecognized providers — requests with unknown providers will fail at runtime. Draft store version bumped to 6; persisted drafts with old option shapes are coerced on load via coerceProviderOptionSelections.

Macroscope summarized bf23358.


Open in Devin Review

- Replace provider option objects with shared selection arrays
- Update Claude, Codex, Cursor, and OpenCode routing and adapters
- Refresh tests to use the new model selection shape
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7c29f4b2-1f15-400d-8b4b-6e41516cdc26

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/provider-array-refactor

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 20, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing xhigh-to-max effort mapping for Claude CLI
    • Added normalizeClaudeEffortForCli() that maps xhigh→max and filters ultrathink, and applied it in ClaudeTextGeneration.ts where resolveClaudeEffort's result was being passed directly to the --effort CLI flag.
  • ✅ Fixed: Duplicated prompt-injected descriptor lookup logic across files
    • Extracted the duplicated .find() logic into a shared findPromptInjectedDescriptor() utility in @t3tools/shared/model and updated both ClaudeAdapter.ts and ChatView.tsx to use it.

Create PR

Or push these changes by commenting:

@cursor push 90d8261e21
Preview (90d8261e21)
diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
--- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
@@ -31,6 +31,7 @@
 import { getProviderOptionCurrentValue, getProviderOptionDescriptors } from "@t3tools/shared/model";
 import {
   getClaudeModelCapabilities,
+  normalizeClaudeEffortForCli,
   resolveClaudeApiModelId,
   resolveClaudeEffort,
 } from "../../provider/Layers/ClaudeProvider.ts";
@@ -95,7 +96,7 @@
     const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
     const rawEffortValue = getProviderOptionCurrentValue(findDescriptor("effort"));
     const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : undefined;
-    const resolvedEffort = resolveClaudeEffort(caps, rawEffort);
+    const resolvedEffort = normalizeClaudeEffortForCli(resolveClaudeEffort(caps, rawEffort));
     const thinkingDescriptor = findDescriptor("thinking");
     const fastModeDescriptor = findDescriptor("fastMode");
     const thinking =

diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -43,6 +43,7 @@
 } from "@t3tools/contracts";
 import {
   applyClaudePromptEffortPrefix,
+  findPromptInjectedDescriptor,
   getProviderOptionDescriptors,
   getModelSelectionOptionValue,
   trimOrNull,
@@ -67,6 +68,7 @@
 import { ServerSettingsService } from "../../serverSettings.ts";
 import {
   getClaudeModelCapabilities,
+  normalizeClaudeEffortForCli,
   resolveClaudeApiModelId,
   resolveClaudeEffort,
 } from "./ClaudeProvider.ts";
@@ -220,16 +222,8 @@
 }
 
 function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null {
-  if (!effort) {
-    return null;
-  }
-  if (effort === "ultrathink") {
-    return null;
-  }
-  if (effort === "xhigh") {
-    return "max";
-  }
-  return effort as ClaudeSdkEffort;
+  const normalized = normalizeClaudeEffortForCli(effort);
+  return normalized ? (normalized as ClaudeSdkEffort) : null;
 }
 
 function isClaudeInterruptedMessage(message: string): boolean {
@@ -569,23 +563,10 @@
     input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
   const caps = getClaudeModelCapabilities(claudeModel);
 
-  // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink").
-  // Normal Claude effort resolution strips prompt-injected values back to the model default,
-  // so prompt formatting must look at the raw selection value directly.
   const trimmedEffort = trimOrNull(rawEffort);
-  const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find(
-    (descriptor) =>
-      descriptor.type === "select" &&
-      (descriptor.id === "effort" ||
-        descriptor.id === "reasoningEffort" ||
-        descriptor.id === "reasoning" ||
-        descriptor.id === "variant") &&
-      (descriptor.promptInjectedValues?.length ?? 0) > 0,
-  );
+  const promptInjectedDescriptor = findPromptInjectedDescriptor(caps);
   const promptEffort =
-    trimmedEffort &&
-    promptInjectedDescriptor?.type === "select" &&
-    promptInjectedDescriptor.promptInjectedValues?.includes(trimmedEffort)
+    trimmedEffort && promptInjectedDescriptor?.promptInjectedValues?.includes(trimmedEffort)
       ? trimmedEffort
       : null;
   return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);

diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -221,6 +221,20 @@
   return typeof value === "string" ? value : undefined;
 }
 
+/**
+ * Maps a T3-internal effort value to a Claude CLI/SDK-compatible effort value.
+ * - `"xhigh"` (T3-internal level between high and max) maps to `"max"`.
+ * - `"ultrathink"` is prompt-injected only; returns `undefined` so the CLI
+ *    flag is omitted entirely.
+ * - All other values pass through unchanged.
+ */
+export function normalizeClaudeEffortForCli(effort: string | null | undefined): string | undefined {
+  if (!effort) return undefined;
+  if (effort === "ultrathink") return undefined;
+  if (effort === "xhigh") return "max";
+  return effort;
+}
+
 export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {
   switch (getModelSelectionOptionValue(modelSelection, "contextWindow")) {
     case "1m":

diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -28,7 +28,7 @@
 import {
   applyClaudePromptEffortPrefix,
   createModelSelection,
-  getProviderOptionDescriptors,
+  findPromptInjectedDescriptor,
 } from "@t3tools/shared/model";
 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
 import { truncate } from "@t3tools/shared/String";
@@ -309,20 +309,8 @@
   text: string;
 }): string {
   const caps = getProviderModelCapabilities(params.models, params.model, params.provider);
-  const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find(
-    (descriptor) =>
-      descriptor.type === "select" &&
-      (descriptor.id === "reasoningEffort" ||
-        descriptor.id === "effort" ||
-        descriptor.id === "reasoning" ||
-        descriptor.id === "variant") &&
-      (descriptor.promptInjectedValues?.length ?? 0) > 0,
-  );
-  if (
-    params.effort &&
-    promptInjectedDescriptor?.type === "select" &&
-    promptInjectedDescriptor.promptInjectedValues?.includes(params.effort)
-  ) {
+  const promptInjectedDescriptor = findPromptInjectedDescriptor(caps);
+  if (params.effort && promptInjectedDescriptor?.promptInjectedValues?.includes(params.effort)) {
     return applyClaudePromptEffortPrefix(params.text, params.effort);
   }
   return params.text;

diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts
--- a/packages/shared/src/model.ts
+++ b/packages/shared/src/model.ts
@@ -292,6 +292,25 @@
   } as ModelSelection;
 }
 
+const PROMPT_INJECTED_DESCRIPTOR_IDS = new Set([
+  "effort",
+  "reasoningEffort",
+  "reasoning",
+  "variant",
+]);
+
+export function findPromptInjectedDescriptor(
+  caps: ModelCapabilities,
+): Extract<ProviderOptionDescriptor, { type: "select" }> | undefined {
+  const descriptor = getProviderOptionDescriptors({ caps }).find(
+    (d) =>
+      d.type === "select" &&
+      PROMPT_INJECTED_DESCRIPTOR_IDS.has(d.id) &&
+      (d.promptInjectedValues?.length ?? 0) > 0,
+  );
+  return descriptor?.type === "select" ? descriptor : undefined;
+}
+
 export function applyClaudePromptEffortPrefix(
   text: string,
   effort: string | null | undefined,

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/provider/Layers/ClaudeProvider.ts
Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts Outdated
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 20, 2026

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Comment thread apps/server/src/orchestration/Layers/ProviderCommandReactor.ts Outdated
Comment thread apps/server/src/git/Layers/ClaudeTextGeneration.ts Outdated
readonly onCommit: (next: string) => void;
};

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low ui/draft-input.tsx:11

The spread order {...rest} {...bag} causes caller-provided onFocus, onBlur, and onKeyDown handlers to be silently overwritten by useCommitOnBlur's handlers. Since DraftInputProps extends InputProps (minus value/onChange/defaultValue), callers can legally pass these handlers, but they never execute at runtime. Consider composing the handlers so both the caller's and the internal handlers fire.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ui/draft-input.tsx around line 11:

The spread order `{...rest} {...bag}` causes caller-provided `onFocus`, `onBlur`, and `onKeyDown` handlers to be silently overwritten by `useCommitOnBlur`'s handlers. Since `DraftInputProps` extends `InputProps` (minus `value`/`onChange`/`defaultValue`), callers can legally pass these handlers, but they never execute at runtime. Consider composing the handlers so both the caller's and the internal handlers fire.

Evidence trail:
apps/web/src/components/ui/draft-input.tsx lines 6-9 (DraftInputProps extends InputProps minus only value/onChange/defaultValue), line 20 (spread order `{...rest} {...bag}`); apps/web/src/components/ui/input.tsx lines 8-11 (InputProps includes all standard input props including onFocus/onBlur/onKeyDown); apps/web/src/hooks/useCommitOnBlur.ts lines 31-45 (hook returns onFocus, onBlur, onKeyDown handlers)

@juliusmarminge juliusmarminge force-pushed the t3code/provider-array-refactor branch from 8a82b53 to 29261cf Compare April 21, 2026 23:41
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Prompt injection descriptor search overly broad, mismatched with effort source
    • Extracted a shared resolvePromptInjectedEffort helper that iterates all descriptors and checks each one's promptInjectedValues independently, replacing the fragile find() across four candidate IDs in both ClaudeAdapter.ts and ChatView.tsx.

Create PR

Or push these changes by commenting:

@cursor push 78fa16c7e7
Preview (78fa16c7e7)
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -43,9 +43,9 @@
 } from "@t3tools/contracts";
 import {
   applyClaudePromptEffortPrefix,
+  getModelSelectionOptionValue,
   getProviderOptionDescriptors,
-  getModelSelectionOptionValue,
-  trimOrNull,
+  resolvePromptInjectedEffort,
 } from "@t3tools/shared/model";
 import {
   Cause,
@@ -569,25 +569,7 @@
     input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
   const caps = getClaudeModelCapabilities(claudeModel);
 
-  // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink").
-  // Normal Claude effort resolution strips prompt-injected values back to the model default,
-  // so prompt formatting must look at the raw selection value directly.
-  const trimmedEffort = trimOrNull(rawEffort);
-  const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find(
-    (descriptor) =>
-      descriptor.type === "select" &&
-      (descriptor.id === "effort" ||
-        descriptor.id === "reasoningEffort" ||
-        descriptor.id === "reasoning" ||
-        descriptor.id === "variant") &&
-      (descriptor.promptInjectedValues?.length ?? 0) > 0,
-  );
-  const promptEffort =
-    trimmedEffort &&
-    promptInjectedDescriptor?.type === "select" &&
-    promptInjectedDescriptor.promptInjectedValues?.includes(trimmedEffort)
-      ? trimmedEffort
-      : null;
+  const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);
   return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
 }
 

diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -28,7 +28,7 @@
 import {
   applyClaudePromptEffortPrefix,
   createModelSelection,
-  getProviderOptionDescriptors,
+  resolvePromptInjectedEffort,
 } from "@t3tools/shared/model";
 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
 import { truncate } from "@t3tools/shared/String";
@@ -309,23 +309,8 @@
   text: string;
 }): string {
   const caps = getProviderModelCapabilities(params.models, params.model, params.provider);
-  const promptInjectedDescriptor = getProviderOptionDescriptors({ caps }).find(
-    (descriptor) =>
-      descriptor.type === "select" &&
-      (descriptor.id === "reasoningEffort" ||
-        descriptor.id === "effort" ||
-        descriptor.id === "reasoning" ||
-        descriptor.id === "variant") &&
-      (descriptor.promptInjectedValues?.length ?? 0) > 0,
-  );
-  if (
-    params.effort &&
-    promptInjectedDescriptor?.type === "select" &&
-    promptInjectedDescriptor.promptInjectedValues?.includes(params.effort)
-  ) {
-    return applyClaudePromptEffortPrefix(params.text, params.effort);
-  }
-  return params.text;
+  const promptEffort = resolvePromptInjectedEffort(caps, params.effort);
+  return applyClaudePromptEffortPrefix(params.text, promptEffort);
 }
 const SCRIPT_TERMINAL_COLS = 120;
 const SCRIPT_TERMINAL_ROWS = 30;

diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts
--- a/packages/shared/src/model.ts
+++ b/packages/shared/src/model.ts
@@ -292,6 +292,29 @@
   } as ModelSelection;
 }
 
+/**
+ * Returns the effort value if it is a prompt-injected value according to
+ * any select descriptor in the given capabilities, or null otherwise.
+ *
+ * Unlike a single `find`, this checks every descriptor so that the
+ * correct descriptor's `promptInjectedValues` list is consulted even when
+ * multiple select descriptors exist.
+ */
+export function resolvePromptInjectedEffort(
+  caps: ModelCapabilities,
+  rawEffort: string | null | undefined,
+): string | null {
+  const trimmed = trimOrNull(rawEffort);
+  if (!trimmed) return null;
+  const descriptors = getProviderOptionDescriptors({ caps });
+  for (const descriptor of descriptors) {
+    if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) {
+      return trimmed;
+    }
+  }
+  return null;
+}
+
 export function applyClaudePromptEffortPrefix(
   text: string,
   effort: string | null | undefined,

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/provider/Layers/ClaudeAdapter.ts Outdated
Comment thread apps/server/src/serverSettings.test.ts
juliusmarminge and others added 3 commits April 21, 2026 17:12
Resolves conflict in apps/server/src/provider/Layers/OpenCodeProvider.ts:
both sides added a constant after `const PROVIDER` (OPENCODE_PRESENTATION
from our side, MINIMUM_OPENCODE_VERSION from main). Kept both, and added
`presentation: OPENCODE_PRESENTATION` to the new too-old-version
buildServerProvider call from main so it satisfies the required field
introduced on this branch.
Migration 016 introduced modelSelection with `options` stored as a
per-provider object (e.g. `{ effort: "max", fastMode: true }`). The
recent contracts refactor reshaped `options` to an array of generic
`{ id, value }` selections. Stored rows from before the reshape still
carry the object shape and fail to decode with `Schema.fromJsonString(
ModelSelection)`.

This migration rewrites the legacy object shape into the canonical
array shape in three places:
  - projection_threads.model_selection_json.options
  - projection_projects.default_model_selection_json.options
  - orchestration_events.payload_json modelSelection / defaultModelSelection
    (thread.created, thread.meta-updated, thread.turn-start-requested,
     project.created, project.meta-updated)

String values are kept if non-empty after trim; boolean values are
always kept; any other value type (nested objects, numbers, null) is
dropped. This matches the permissive client-side normalizer in
composerDraftStore.

Records whose `options` is already an array are left untouched.
…escriptor search

The prompt injection descriptor lookup in both ClaudeAdapter.ts and
ChatView.tsx used find() across four candidate descriptor IDs, which
could match the wrong descriptor if multiple select descriptors had
promptInjectedValues. Replace the fragile pattern with a shared helper
that iterates all descriptors and checks each one's promptInjectedValues
list independently.
@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 22, 2026

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Hardcoded provider in unknown-driver error is misleading
    • Replaced hardcoded "codex" with threadProviderCandidate so the error message correctly identifies the actual unknown driver slug.
  • ✅ Fixed: Double effort resolution yields misleading variable naming and redundant work
    • Replaced descriptor-based double resolution with getModelSelectionOptionValue to extract the raw effort value directly (consistent with ClaudeAdapter.ts), eliminating the redundant resolution pass and misleading variable name.

Create PR

Or push these changes by commenting:

@cursor push ed70ae0e94
Preview (ed70ae0e94)
diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts
--- a/apps/server/integration/orchestrationEngine.integration.test.ts
+++ b/apps/server/integration/orchestrationEngine.integration.test.ts
@@ -12,6 +12,7 @@
   ProviderKind,
   ThreadId,
   ModelSelection,
+  ProviderInstanceId,
 } from "@t3tools/contracts";
 import { assert, it } from "@effect/vitest";
 import { Effect, Option, Schema } from "effect";
@@ -116,7 +117,7 @@
       title: "Integration Project",
       workspaceRoot: harness.workspaceDir,
       defaultModelSelection: {
-        provider,
+        instanceId: ProviderInstanceId.make(provider),
         model: defaultModel,
       },
       createdAt,
@@ -129,7 +130,7 @@
       projectId: PROJECT_ID,
       title: "Integration Thread",
       modelSelection: {
-        provider,
+        instanceId: ProviderInstanceId.make(provider),
         model: defaultModel,
       },
       interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -265,7 +266,7 @@
           title: "Integration Project",
           workspaceRoot: harness.workspaceDir,
           defaultModelSelection: {
-            provider: "codex",
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5.3-codex",
           },
           createdAt,
@@ -278,7 +279,7 @@
           projectId: PROJECT_ID,
           title: "Integration Thread",
           modelSelection: {
-            provider: "codex",
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5.3-codex",
           },
           interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -939,7 +940,7 @@
           messageId: "msg-user-claude-initial",
           text: "Use Claude",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });
@@ -996,7 +997,7 @@
           messageId: "msg-user-claude-recover-1",
           text: "Before restart",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });
@@ -1106,7 +1107,7 @@
           messageId: "msg-user-claude-approval",
           text: "Need approval",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });
@@ -1178,7 +1179,7 @@
           messageId: "msg-user-claude-interrupt",
           text: "Start long turn",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });
@@ -1251,7 +1252,7 @@
           messageId: "msg-user-claude-revert-1",
           text: "First Claude edit",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });

diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
--- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts
@@ -1,3 +1,4 @@
+import { ProviderInstanceId } from "@t3tools/contracts";
 import * as NodeServices from "@effect/platform-node/NodeServices";
 import { it } from "@effect/vitest";
 import { Effect, FileSystem, Layer, Path } from "effect";
@@ -264,7 +265,7 @@
           cwd: process.cwd(),
           message: "Please investigate reconnect failures after restarting the session.",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });
@@ -294,7 +295,7 @@
           cwd: process.cwd(),
           message: "Name this thread.",
           modelSelection: {
-            provider: "claudeAgent",
+            instanceId: ProviderInstanceId.make("claudeAgent"),
             model: "claude-sonnet-4-6",
           },
         });

diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
--- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts
+++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts
@@ -10,7 +10,7 @@
 import { Effect, Layer, Option, Schema, Stream } from "effect";
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
 
-import { ClaudeModelSelection } from "@t3tools/contracts";
+import { type ModelSelection } from "@t3tools/contracts";
 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
 
 import { TextGenerationError } from "@t3tools/contracts";
@@ -28,7 +28,7 @@
   sanitizeThreadTitle,
   toJsonSchemaObject,
 } from "../Utils.ts";
-import { getProviderOptionCurrentValue, getProviderOptionDescriptors } from "@t3tools/shared/model";
+import { getModelSelectionOptionValue, getProviderOptionDescriptors } from "@t3tools/shared/model";
 import {
   getClaudeModelCapabilities,
   resolveClaudeApiModelId,
@@ -84,7 +84,7 @@
     cwd: string;
     prompt: string;
     outputSchemaJson: S;
-    modelSelection: ClaudeModelSelection;
+    modelSelection: ModelSelection;
   }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
     const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
     const caps = getClaudeModelCapabilities(modelSelection.model);
@@ -92,12 +92,13 @@
       caps,
       selections: modelSelection.options,
     });
-    const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
-    const rawEffortValue = getProviderOptionCurrentValue(findDescriptor("effort"));
-    const rawEffort = typeof rawEffortValue === "string" ? rawEffortValue : undefined;
-    const resolvedEffort = resolveClaudeEffort(caps, rawEffort);
-    const thinkingDescriptor = findDescriptor("thinking");
-    const fastModeDescriptor = findDescriptor("fastMode");
+    const rawEffort = getModelSelectionOptionValue(modelSelection, "effort");
+    const resolvedEffort = resolveClaudeEffort(
+      caps,
+      typeof rawEffort === "string" ? rawEffort : undefined,
+    );
+    const thinkingDescriptor = descriptors.find((descriptor) => descriptor.id === "thinking");
+    const fastModeDescriptor = descriptors.find((descriptor) => descriptor.id === "fastMode");
     const thinking =
       thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;
     const fastMode =
@@ -228,7 +229,7 @@
       includeBranch: input.includeBranch === true,
     });
 
-    if (input.modelSelection.provider !== "claudeAgent") {
+    if (input.modelSelection.instanceId !== "claudeAgent") {
       return yield* new TextGenerationError({
         operation: "generateCommitMessage",
         detail: "Invalid model selection.",
@@ -263,7 +264,7 @@
       diffPatch: input.diffPatch,
     });
 
-    if (input.modelSelection.provider !== "claudeAgent") {
+    if (input.modelSelection.instanceId !== "claudeAgent") {
       return yield* new TextGenerationError({
         operation: "generatePrContent",
         detail: "Invalid model selection.",
@@ -292,7 +293,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "claudeAgent") {
+    if (input.modelSelection.instanceId !== "claudeAgent") {
       return yield* new TextGenerationError({
         operation: "generateBranchName",
         detail: "Invalid model selection.",
@@ -320,7 +321,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "claudeAgent") {
+    if (input.modelSelection.instanceId !== "claudeAgent") {
       return yield* new TextGenerationError({
         operation: "generateThreadTitle",
         detail: "Invalid model selection.",

diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
--- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts
@@ -10,10 +10,7 @@
 import { TextGeneration } from "../Services/TextGeneration.ts";
 import { ServerSettingsService } from "../../serverSettings.ts";
 
-const DEFAULT_TEST_MODEL_SELECTION = {
-  provider: "codex" as const,
-  model: "gpt-5.4-mini",
-};
+const DEFAULT_TEST_MODEL_SELECTION = createModelSelection("codex", "gpt-5.4-mini");
 
 const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
   Layer.provideMerge(ServerSettingsService.layerTest()),

diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts
--- a/apps/server/src/git/Layers/CodexTextGeneration.ts
+++ b/apps/server/src/git/Layers/CodexTextGeneration.ts
@@ -3,7 +3,7 @@
 import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect";
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
 
-import { CodexModelSelection } from "@t3tools/contracts";
+import { type ModelSelection } from "@t3tools/contracts";
 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
 
 import { resolveAttachmentPath } from "../../attachmentStore.ts";
@@ -140,7 +140,7 @@
     outputSchemaJson: S;
     imagePaths?: ReadonlyArray<string>;
     cleanupPaths?: ReadonlyArray<string>;
-    modelSelection: CodexModelSelection;
+    modelSelection: ModelSelection;
   }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
     const schemaPath = yield* writeTempFile(
       operation,
@@ -285,7 +285,7 @@
       includeBranch: input.includeBranch === true,
     });
 
-    if (input.modelSelection.provider !== "codex") {
+    if (input.modelSelection.instanceId !== "codex") {
       return yield* new TextGenerationError({
         operation: "generateCommitMessage",
         detail: "Invalid model selection.",
@@ -320,7 +320,7 @@
       diffPatch: input.diffPatch,
     });
 
-    if (input.modelSelection.provider !== "codex") {
+    if (input.modelSelection.instanceId !== "codex") {
       return yield* new TextGenerationError({
         operation: "generatePrContent",
         detail: "Invalid model selection.",
@@ -353,7 +353,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "codex") {
+    if (input.modelSelection.instanceId !== "codex") {
       return yield* new TextGenerationError({
         operation: "generateBranchName",
         detail: "Invalid model selection.",
@@ -386,7 +386,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "codex") {
+    if (input.modelSelection.instanceId !== "codex") {
       return yield* new TextGenerationError({
         operation: "generateThreadTitle",
         detail: "Invalid model selection.",

diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts
--- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts
@@ -9,7 +9,7 @@
 import { createModelSelection } from "@t3tools/shared/model";
 import { expect } from "vitest";
 
-import { ServerSettingsError } from "@t3tools/contracts";
+import { ServerSettingsError, ProviderInstanceId } from "@t3tools/contracts";
 
 import { ServerConfig } from "../../config.ts";
 import { TextGeneration } from "../Services/TextGeneration.ts";
@@ -223,7 +223,7 @@
           stagedSummary: "M README.md",
           stagedPatch: "diff --git a/README.md b/README.md",
           modelSelection: {
-            provider: "cursor",
+            instanceId: ProviderInstanceId.make("cursor"),
             model: "composer-2",
           },
         });
@@ -248,7 +248,7 @@
           cwd: process.cwd(),
           message: "Fix the reconnect spinner after a resumed session.",
           modelSelection: {
-            provider: "cursor",
+            instanceId: ProviderInstanceId.make("cursor"),
             model: "composer-2",
           },
         });
@@ -280,7 +280,7 @@
           stagedPatch:
             "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
           modelSelection: {
-            provider: "cursor",
+            instanceId: ProviderInstanceId.make("cursor"),
             model: "composer-2",
           },
         });

diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts
--- a/apps/server/src/git/Layers/CursorTextGeneration.ts
+++ b/apps/server/src/git/Layers/CursorTextGeneration.ts
@@ -1,7 +1,7 @@
 import { Effect, Layer, Option, Ref, Schema } from "effect";
 import { ChildProcessSpawner } from "effect/unstable/process";
 
-import { CursorModelSelection } from "@t3tools/contracts";
+import { type ModelSelection } from "@t3tools/contracts";
 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
 
 import { TextGenerationError } from "@t3tools/contracts";
@@ -74,7 +74,7 @@
     cwd: string;
     prompt: string;
     outputSchemaJson: S;
-    modelSelection: CursorModelSelection;
+    modelSelection: ModelSelection;
   }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
     Effect.gen(function* () {
       const cursorSettings = yield* Effect.map(
@@ -186,7 +186,7 @@
       includeBranch: input.includeBranch === true,
     });
 
-    if (input.modelSelection.provider !== "cursor") {
+    if (input.modelSelection.instanceId !== "cursor") {
       return yield* new TextGenerationError({
         operation: "generateCommitMessage",
         detail: "Invalid model selection.",
@@ -221,7 +221,7 @@
       diffPatch: input.diffPatch,
     });
 
-    if (input.modelSelection.provider !== "cursor") {
+    if (input.modelSelection.instanceId !== "cursor") {
       return yield* new TextGenerationError({
         operation: "generatePrContent",
         detail: "Invalid model selection.",
@@ -250,7 +250,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "cursor") {
+    if (input.modelSelection.instanceId !== "cursor") {
       return yield* new TextGenerationError({
         operation: "generateBranchName",
         detail: "Invalid model selection.",
@@ -278,7 +278,7 @@
       attachments: input.attachments,
     });
 
-    if (input.modelSelection.provider !== "cursor") {
+    if (input.modelSelection.instanceId !== "cursor") {
       return yield* new TextGenerationError({
         operation: "generateThreadTitle",
         detail: "Invalid model selection.",

diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts
--- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts
+++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts
@@ -1,3 +1,4 @@
+import { ProviderInstanceId } from "@t3tools/contracts";
 import * as NodeServices from "@effect/platform-node/NodeServices";
 import { it } from "@effect/vitest";
 import { Duration, Effect, Layer } from "effect";
@@ -97,7 +98,7 @@
 };
 
 const DEFAULT_TEST_MODEL_SELECTION = {
-  provider: "opencode" as const,
+  instanceId: ProviderInstanceId.make("opencode"),
   model: "openai/gpt-5",
 };
 

diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts
--- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts
+++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts
@@ -1,11 +1,7 @@
 import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
 import * as Semaphore from "effect/Semaphore";
 
-import {
-  TextGenerationError,
-  type ChatAttachment,
-  type OpenCodeModelSelection,
-} from "@t3tools/contracts";
+import { TextGenerationError, type ChatAttachment, type ModelSelection } from "@t3tools/contracts";
 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
 import { getModelSelectionOptionValue } from "@t3tools/shared/model";
 
@@ -267,7 +263,7 @@
     readonly cwd: string;
     readonly prompt: string;
     readonly outputSchemaJson: S;
-    readonly modelSelection: OpenCodeModelSelection;
+    readonly modelSelection: ModelSelection;
     readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
   }) {
     const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
@@ -384,7 +380,7 @@
   const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
     "OpenCodeTextGeneration.generateCommitMessage",
   )(function* (input) {
-    if (input.modelSelection.provider !== "opencode") {
+    if (input.modelSelection.instanceId !== "opencode") {
       return yield* new TextGenerationError({
         operation: "generateCommitMessage",
         detail: "Invalid model selection.",
@@ -417,7 +413,7 @@
   const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
     "OpenCodeTextGeneration.generatePrContent",
   )(function* (input) {
-    if (input.modelSelection.provider !== "opencode") {
+    if (input.modelSelection.instanceId !== "opencode") {
       return yield* new TextGenerationError({
         operation: "generatePrContent",
         detail: "Invalid model selection.",
@@ -448,7 +444,7 @@
   const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
     "OpenCodeTextGeneration.generateBranchName",
   )(function* (input) {
-    if (input.modelSelection.provider !== "opencode") {
+    if (input.modelSelection.instanceId !== "opencode") {
       return yield* new TextGenerationError({
         operation: "generateBranchName",
         detail: "Invalid model selection.",
@@ -476,7 +472,7 @@
   const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
     "OpenCodeTextGeneration.generateThreadTitle",
   )(function* (input) {
-    if (input.modelSelection.provider !== "opencode") {
+    if (input.modelSelection.instanceId !== "opencode") {
       return yield* new TextGenerationError({
         operation: "generateThreadTitle",
         detail: "Invalid model selection.",

diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts
--- a/apps/server/src/git/Layers/RoutingTextGeneration.ts
+++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts
@@ -3,7 +3,7 @@
  * Codex CLI or Claude CLI implementation based on the provider in each
  * request input.
  *
- * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to
+ * When `modelSelection.instanceId` resolves to a `"claudeAgent"` driver the request is forwarded to
  * the Claude layer; for any other value (including the default `undefined`) it
  * falls through to the Codex layer.
  *
@@ -11,6 +11,8 @@
  */
 import { Effect, Layer, Context } from "effect";
 
+import { isBuiltInDriverId, TextGenerationError } from "@t3tools/contracts";
+
 import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts";
 import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
 import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
@@ -47,17 +49,45 @@
     claudeAgent: yield* ClaudeTextGen,
     cursor: yield* CursorTextGen,
     opencode: yield* OpenCodeTextGen,
+  } as const;
+
+  // `ModelSelection.provider` is an open driver-id slug — it may name a
+  // driver this build doesn't ship (fork / rollback case). Surface that
+  // as a structured `TextGenerationError` instead of an `undefined`-key
+  // crash; callers can decide whether to retry under a different driver
+  // or surface "driver not installed" to the user.
+  const route = <Op extends keyof TextGenerationShape>(
+    operation: Op,
+    provider: string,
+  ): Effect.Effect<TextGenerationShape[Op], TextGenerationError, never> => {
+    if (!isBuiltInDriverId(provider)) {
+      return Effect.fail(
+        new TextGenerationError({
+          operation,
+          detail: `No text-generation driver registered for provider "${provider}".`,
+        }),
+      );
+    }
+    return Effect.succeed(byProvider[provider][operation]);
   };
 
   return {
     generateCommitMessage: (input) =>
-      byProvider[input.modelSelection.provider].generateCommitMessage(input),
+      route("generateCommitMessage", input.modelSelection.instanceId).pipe(
+        Effect.flatMap((fn) => fn(input)),
+      ),
     generatePrContent: (input) =>
-      byProvider[input.modelSelection.provider].generatePrContent(input),
+      route("generatePrContent", input.modelSelection.instanceId).pipe(
+        Effect.flatMap((fn) => fn(input)),
+      ),
     generateBranchName: (input) =>
-      byProvider[input.modelSelection.provider].generateBranchName(input),
+      route("generateBranchName", input.modelSelection.instanceId).pipe(
+        Effect.flatMap((fn) => fn(input)),
+      ),
     generateThreadTitle: (input) =>
-      byProvider[input.modelSelection.provider].generateThreadTitle(input),
+      route("generateThreadTitle", input.modelSelection.instanceId).pipe(
+        Effect.flatMap((fn) => fn(input)),
+      ),
   } satisfies TextGenerationShape;
 });
 

diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -3,8 +3,13 @@
 import path from "node:path";
 import { execFileSync } from "node:child_process";
 
-import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
 import {
+  ProviderKind,
+  ProviderRuntimeEvent,
+  ProviderSession,
+  ProviderInstanceId,
+} from "@t3tools/contracts";
+import {
   CommandId,
   DEFAULT_PROVIDER_INTERACTION_MODE,
   EventId,
@@ -315,7 +320,7 @@
         title: "Test Project",
         workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -329,7 +334,7 @@
         projectId: asProjectId("project-1"),
         title: "Thread",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,

diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -7,6 +7,7 @@
   ThreadId,
   TurnId,
   type OrchestrationEvent,
+  ProviderInstanceId,
 } from "@t3tools/contracts";
 import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
 import { describe, expect, it } from "vitest";
@@ -104,7 +105,7 @@
           title: "Bootstrap Project",
           workspaceRoot: "/tmp/project-bootstrap",
           defaultModelSelection: {
-            provider: "codex" as const,
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5-codex",
           },
           scripts: [],
@@ -119,7 +120,7 @@
           projectId: asProjectId("project-bootstrap"),
           title: "Bootstrap Thread",
           modelSelection: {
-            provider: "codex" as const,
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5-codex",
           },
           interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -198,7 +199,7 @@
         title: "Project 1",
         workspaceRoot: "/tmp/project-1",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -212,7 +213,7 @@
         projectId: asProjectId("project-1"),
         title: "Thread",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -258,7 +259,7 @@
         title: "Project Archive",
         workspaceRoot: "/tmp/project-archive",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -272,7 +273,7 @@
         projectId: asProjectId("project-archive"),
         title: "Archive me",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -325,7 +326,7 @@
         title: "Replay Project",
         workspaceRoot: "/tmp/project-replay",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -339,7 +340,7 @@
         projectId: asProjectId("project-replay"),
         title: "replay",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -383,7 +384,7 @@
         title: "Stream Project",
         workspaceRoot: "/tmp/project-stream",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -407,7 +408,7 @@
           projectId: asProjectId("project-stream"),
           title: "domain-stream",
           modelSelection: {
-            provider: "codex",
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5-codex",
           },
           interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -444,7 +445,7 @@
         title: "Ack Project",
         workspaceRoot: "/tmp/project-ack",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -459,7 +460,7 @@
         projectId: asProjectId("project-ack"),
         title: "Ack Thread",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -496,7 +497,7 @@
           projectId: asProjectId("project-missing"),
           title: "Missing Project Thread",
           modelSelection: {
-            provider: "codex",
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5-codex",
           },
           interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -533,7 +534,7 @@
         title: "Turn Diff Project",
         workspaceRoot: "/tmp/project-turn-diff",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -547,7 +548,7 @@
         projectId: asProjectId("project-turn-diff"),
         title: "Turn diff thread",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -652,7 +653,7 @@
         title: "Flaky Project",
         workspaceRoot: "/tmp/project-flaky",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -668,7 +669,7 @@
           projectId: asProjectId("project-flaky"),
           title: "flaky-fail",
           modelSelection: {
-            provider: "codex",
+            instanceId: ProviderInstanceId.make("codex"),
             model: "gpt-5-codex",
           },
           interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -688,7 +689,7 @@
         projectId: asProjectId("project-flaky"),
         title: "flaky-ok",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -747,7 +748,7 @@
         title: "Atomic Project",
         workspaceRoot: "/tmp/project-atomic",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -761,7 +762,7 @@
         projectId: asProjectId("project-atomic"),
         title: "atomic",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -890,7 +891,7 @@
         title: "Sync Project",
         workspaceRoot: "/tmp/project-sync",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -904,7 +905,7 @@
         projectId: asProjectId("project-sync"),
         title: "sync-before",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -975,7 +976,7 @@
         title: "Duplicate Project",
         workspaceRoot: "/tmp/project-duplicate",
         defaultModelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         createdAt,
@@ -990,7 +991,7 @@
         projectId: asProjectId("project-duplicate"),
         title: "duplicate",
         modelSelection: {
-          provider: "codex",
+          instanceId: ProviderInstanceId.make("codex"),
           model: "gpt-5-codex",
         },
         interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
@@ -1010,7 +1011,7 @@
           projectId: asProjectId("project-duplicate"),
           title: "duplicate",
           modelSelection: {
-            provider: "codex",
... diff truncated: showing 800 of 6098 lines

You can send follow-ups to the cloud agent here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 78fa16c

…escriptor search

The prompt injection descriptor lookup in both ClaudeAdapter.ts and
ChatView.tsx used find() across four candidate descriptor IDs, which
could match the wrong descriptor if multiple select descriptors had
promptInjectedValues. Replace the fragile pattern with a shared helper
that iterates all descriptors and checks each one's promptInjectedValues
list independently.

Applied via @cursor push command
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push ed70ae0

@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 22, 2026

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unused fakeOpenCodeSnapshot variable in test file
    • Removed the unused fakeOpenCodeSnapshot constant and its ServerProvider type annotation (which is still used elsewhere) from the test file.

Create PR

Or push these changes by commenting:

@cursor push 0f34ecdfc8
Preview (0f34ecdfc8)
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -54,20 +54,6 @@
   };
 }
 
-const fakeOpenCodeSnapshot: ServerProvider = {
-  provider: "opencode",
-  status: "warning",
-  enabled: true,
-  installed: false,
-  auth: { status: "unknown" },
-  checkedAt: "2026-03-25T00:00:00.000Z",
-  version: null,
-  models: [],
-  slashCommands: [],
-  skills: [],
-  message: "OpenCode test stub",
-};
-
 function mockHandle(result: { stdout: string; stderr: string; code: number }) {
   return ChildProcessSpawner.makeHandle({
     pid: ChildProcessSpawner.ProcessId(1),

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/provider/Layers/ProviderRegistry.test.ts Outdated
juliusmarminge and others added 3 commits April 21, 2026 18:47
- Fix Format CI: apply oxfmt to 026_CanonicalizeModelSelectionOptions.test.ts
- Add shared normalizeClaudeCliEffort() in ClaudeProvider that maps the
  Opus 4.7 capability "xhigh" to the CLI-accepted "max" and filters
  "ultrathink" (prompt-prefix mode). Use it in ClaudeTextGeneration
  before passing --effort, fixing the medium-severity bug where
  resolveClaudeEffort's raw output could land on the CLI unmapped.
- Re-point ClaudeAdapter's getEffectiveClaudeAgentEffort at the shared
  normalizer so SDK and CLI paths can't drift.
- Simplify ClaudeTextGeneration effort resolution: read the raw selection
  via getModelSelectionOptionValue (matching ClaudeAdapter's pattern)
  instead of going through descriptors + currentValue, then passing to
  resolveClaudeEffort a second time. Resolves the low-severity "double
  effort resolution / misleading rawEffortValue naming" comment.
- Remove unused fakeOpenCodeSnapshot fixture from ProviderRegistry.test.
The pre-refactor test asserted `toMatchObject({ options: { fastMode: true } })`
against an object-shaped `options`, which tolerated extra keys like
`reasoningEffort`. When the refactor converted `options` to a generic array
of `{ id, value }` selections the assertion was mechanically rewritten, but
`toMatchObject` compares arrays strictly (length + positional match), so the
extra sticky `reasoningEffort` entry now fails the match even though runtime
behaviour is unchanged.

Wrap the `options` array in `expect.arrayContaining([...])` so the assertion
keeps its original intent — "verify the sticky `fastMode` trait carries into
the new draft" — while allowing other sticky entries that previously flew
under the object-match radar.

No production code change; only ChatView.browser.tsx assertions adjusted.
- Add typed string/boolean selection accessors
- Update provider adapters to use typed helpers
- Cover selection helper behavior with tests

Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Default effort always sent when user selects none
    • Added early return of undefined in resolveClaudeEffort when raw is null/undefined, preventing the function from falling through to getProviderOptionCurrentValue which would return the model's default effort.

Create PR

Or push these changes by commenting:

@cursor push 93c24a8689
Preview (93c24a8689)
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -212,9 +212,12 @@
   caps: ModelCapabilities,
   raw: string | null | undefined,
 ): string | undefined {
+  if (!raw) {
+    return undefined;
+  }
   const descriptors = getProviderOptionDescriptors({
     caps,
-    ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}),
+    selections: [{ id: "effort", value: raw }],
   });
   const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort");
   const value = getProviderOptionCurrentValue(effortDescriptor);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 8b2ab13. Configure here.

Comment thread apps/server/src/provider/Layers/ClaudeProvider.ts
- Decode old object-shaped `options` into canonical arrays
- Cover settings and orchestration round trips
@juliusmarminge juliusmarminge merged commit 8d1d699 into main Apr 23, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/provider-array-refactor branch April 23, 2026 13:35
yazandabbas pushed a commit to yazandabbas/Presence that referenced this pull request Apr 23, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
(cherry picked from commit 8d1d699)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants