Skip to content

Persist provider-aware model selections#1371

Merged
juliusmarminge merged 40 commits intomainfrom
t3code/provider-kind-model
Mar 25, 2026
Merged

Persist provider-aware model selections#1371
juliusmarminge merged 40 commits intomainfrom
t3code/provider-kind-model

Conversation

@juliusmarminge
Copy link
Member

@juliusmarminge juliusmarminge commented Mar 24, 2026


Note

High Risk
Introduces a breaking schema change from flat model fields to provider-aware ModelSelection across commands/events/projections and applies an irreversible DB migration that rewrites existing orchestration_events payload JSON and drops legacy columns. Also changes provider session restart/switching logic, so regressions could surface as failed session starts or unexpected model/option behavior at runtime.

Overview
Replaces flat model strings with provider-aware ModelSelection across orchestration. Project defaults and thread metadata now persist defaultModelSelection/modelSelection (including provider-scoped options) instead of defaultModel/model, and turn start requests carry modelSelection end-to-end.

Updates persistence and projections to store/read model selections as JSON. Projection repositories and snapshot queries switch to *_selection_json columns, and a new migration 016_CanonicalizeModelSelections backfills projection rows, rewrites legacy event payloads (defaultModel*, model*) into the canonical shape, then drops the old default_model/model columns.

Adjusts provider execution to use modelSelection and capability-based behavior. ProviderCommandReactor now enforces provider binding via modelSelection, caches per-thread selections, preserves the active session model when in-session switching is unsupported, and adapters (CodexAdapter, ClaudeAdapter) map options from modelSelection while improving Claude token-usage tracking (use progress events for context usage; treat result totals as processed tokens).

Written by Cursor Bugbot for commit 3701c8b. This will update automatically on new commits. Configure here.

Note

Replace flat model strings with provider-aware ModelSelection objects across threads, projects, and turn commands

  • Replaces all model: string and separate provider/modelOptions fields with a unified ModelSelection object ({ provider, model, options? }) in orchestration contracts, projections, persistence, provider adapters, and the web composer store.
  • Adds migration 016_CanonicalizeModelSelections.ts to backfill default_model_selection_json and model_selection_json columns, rewrite legacy event payloads, and drop the old default_model/model columns.
  • Introduces per-model capability data in packages/contracts/src/model.ts via MODEL_CAPABILITIES_INDEX, replacing provider-wide effort/flag helpers with a data-driven getModelCapabilities lookup used for effort validation, fast mode, thinking toggle, and prompt-injected effort levels.
  • Replaces the web composer draft store's stickyModel/stickyModelOptions with stickyModelSelectionByProvider and stickyActiveProvider, and bumps COMPOSER_DRAFT_STORAGE_VERSION to 3 with full migration from v1/v2 shapes.
  • Risk: REASONING_EFFORT_OPTIONS_BY_PROVIDER and DEFAULT_REASONING_EFFORT_BY_PROVIDER are removed from the contracts package; any consumer importing them will fail to load.

Macroscope summarized 3701c8b.

- Replace model-only fields with provider/model selections across orchestration
- Add projection schema and migration updates for provider-backed snapshots
- Update server and web tests to use the new selection shape
@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 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: 2f0c7b71-a77d-41ab-8ca0-70ba4ea57dd1

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-kind-model

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 Mar 24, 2026
Copy link
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: Model picker clears reasoning effort and fast mode
    • Preserved existing composerDraft.modelSelection.options in onProviderModelSelect when the provider remains the same, preventing reasoning effort and fast mode settings from being silently cleared on model switch.

Create PR

Or push these changes by commenting:

@cursor push 569dd7bcab
Preview (569dd7bcab)
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
@@ -3135,10 +3135,14 @@
         return;
       }
       const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);
+      const existingOptions = composerDraft.modelSelection?.options;
+      const preserveOptions =
+        existingOptions !== undefined && composerDraft.modelSelection?.provider === provider;
       const nextModelSelection: ModelSelection = {
         provider,
         model: resolvedModel,
-      };
+        ...(preserveOptions ? { options: existingOptions } : {}),
+      } as ModelSelection;
       setComposerDraftModelSelection(activeThread.id, nextModelSelection);
       setStickyComposerModelSelection(nextModelSelection);
       scheduleComposerFocus();
@@ -3150,6 +3154,7 @@
       setComposerDraftModelSelection,
       setStickyComposerModelSelection,
       customModelsByProvider,
+      composerDraft.modelSelection,
     ],
   );
   const setPromptFromTraits = useCallback(

- Keep draft model options when switching models within the same provider
- Decode SQL errors correctly in projection snapshot model selection
- Default missing sticky provider to codex during draft migration
- Preserve default provider model selections in the draft state
- Update composer draft store expectations for sticky/provider-specific models
Copy link
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 fixes for both issues found in the latest run.

  • ✅ Fixed: Runtime-mode-set handler may trigger spurious session restarts
    • Removed the fallback ?? toProviderModelOptions(thread.modelSelection) so that when no cached model options exist, cachedModelOptions remains undefined and the modelOptions key is omitted from the options passed to ensureSessionForThread, preventing the spurious restart.
  • ✅ Fixed: Unreachable fallback chain in plan implementation model selection
    • Simplified nextThreadModelSelection to assign selectedModelSelection directly, removing the unreachable ?? fallback chain since selectedModelSelection is always a non-null ModelSelection from useMemo.

Create PR

Or push these changes by commenting:

@cursor push 14070d6c45
Preview (14070d6c45)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -705,9 +705,7 @@
             return;
           }
           const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
-          const cachedModelOptions =
-            threadModelOptions.get(event.payload.threadId) ??
-            toProviderModelOptions(thread.modelSelection);
+          const cachedModelOptions = threadModelOptions.get(event.payload.threadId);
           yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
             ...(cachedProviderOptions !== undefined
               ? { providerOptions: cachedProviderOptions }

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
@@ -3035,12 +3035,7 @@
       text: implementationPrompt,
     });
     const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));
-    const nextThreadModelSelection: ModelSelection = selectedModelSelection ??
-      activeThread.modelSelection ??
-      activeProject.defaultModelSelection ?? {
-        provider: "codex",
-        model: DEFAULT_MODEL_BY_PROVIDER.codex,
-      };
+    const nextThreadModelSelection: ModelSelection = selectedModelSelection;
 
     sendInFlightRef.current = true;
     beginSendPhase("sending-turn");

@cursor
Copy link
Contributor

cursor bot commented Mar 24, 2026

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

  • ✅ Fixed: Duplicate toProviderModelOptions function across packages
    • Extracted the duplicated toProviderModelOptions function into @t3tools/shared/model and replaced both local copies in ChatView.tsx and ProviderCommandReactor.ts with imports from the shared module.
  • ✅ Fixed: Options column stores JSON "null" string instead of SQL NULL
    • Changed both ProjectionProjects.ts and ProjectionThreads.ts to conditionally call JSON.stringify only when options are present, passing JavaScript null directly when absent so the database stores SQL NULL.

Create PR

Or push these changes by commenting:

@cursor push e0f5c02e35
Preview (e0f5c02e35)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -16,6 +16,7 @@
 } from "@t3tools/contracts";
 import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
 import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
+import { toProviderModelOptions } from "@t3tools/shared/model";
 
 import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
 import { GitCore } from "../../git/Services/GitCore.ts";
@@ -81,17 +82,6 @@
   right: ProviderModelOptions | undefined,
 ): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
 
-function toProviderModelOptions(
-  modelSelection: ModelSelection | undefined,
-): ProviderModelOptions | undefined {
-  if (!modelSelection?.options) {
-    return undefined;
-  }
-  return modelSelection.provider === "codex"
-    ? { codex: modelSelection.options }
-    : { claudeAgent: modelSelection.options };
-}
-
 function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
   const error = Cause.squash(cause);
   if (Schema.is(ProviderAdapterRequestError)(error)) {

diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts
--- a/apps/server/src/persistence/Layers/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts
@@ -77,7 +77,7 @@
           ${row.workspaceRoot},
           ${row.defaultModelSelection?.provider ?? null},
           ${row.defaultModelSelection?.model ?? null},
-          ${JSON.stringify(row.defaultModelSelection?.options ?? null)},
+          ${row.defaultModelSelection?.options != null ? JSON.stringify(row.defaultModelSelection.options) : null},
           ${JSON.stringify(row.scripts)},
           ${row.createdAt},
           ${row.updatedAt},

diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts
--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts
@@ -89,7 +89,7 @@
           ${row.title},
           ${row.modelSelection.provider},
           ${row.modelSelection.model},
-          ${JSON.stringify(row.modelSelection.options ?? null)},
+          ${row.modelSelection.options != null ? JSON.stringify(row.modelSelection.options) : null},
           ${row.runtimeMode},
           ${row.interactionMode},
           ${row.branch},

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,6 +28,7 @@
   getDefaultModel,
   normalizeModelSlug,
   resolveModelSlugForProvider,
+  toProviderModelOptions,
 } from "@t3tools/shared/model";
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -211,16 +212,6 @@
   return provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;
 }
 
-function toProviderModelOptions(
-  modelSelection: ModelSelection | null | undefined,
-): ProviderModelOptions | undefined {
-  if (!modelSelection?.options) {
-    return undefined;
-  }
-  return modelSelection.provider === "codex"
-    ? { codex: modelSelection.options }
-    : { claudeAgent: modelSelection.options };
-}
 const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
 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
@@ -10,7 +10,9 @@
   type ClaudeCodeEffort,
   type CodexModelOptions,
   type CodexReasoningEffort,
+  type ModelSelection,
   type ModelSlug,
+  type ProviderModelOptions,
   type ProviderReasoningEffort,
   type ProviderKind,
 } from "@t3tools/contracts";
@@ -266,4 +268,15 @@
   return `Ultrathink:\n${trimmed}`;
 }
 
+export function toProviderModelOptions(
+  modelSelection: ModelSelection | null | undefined,
+): ProviderModelOptions | undefined {
+  if (!modelSelection?.options) {
+    return undefined;
+  }
+  return modelSelection.provider === "codex"
+    ? { codex: modelSelection.options }
+    : { claudeAgent: modelSelection.options };
+}
+
 export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };

- Keep sticky selection in sync when provider options change
- Add regression test for creating the initial sticky snapshot
- Store missing model options as SQL NULL in project and thread projections
- Stop rehydrating derived model options when restarting provider sessions
- Share model option conversion logic in `packages/shared`
Copy link
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: Redundant double call to extractModelSelectionOptions
    • Stored the result of extractModelSelectionOptions in a local variable and reused it for both the truthiness check and the value assignment.
  • ✅ Fixed: Model options always injected on session restart fallback
    • Changed the fallback to only derive model options from requestedModelSelection (explicit user request) rather than from the thread's persisted modelSelection, preventing silent injection during runtime-mode-triggered restarts.

Create PR

Or push these changes by commenting:

@cursor push 73c8d110db
Preview (73c8d110db)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -249,7 +249,10 @@
     const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
     const desiredModel = desiredModelSelection.model;
     const desiredModelOptions =
-      options?.modelOptions ?? toProviderModelOptions(desiredModelSelection);
+      options?.modelOptions ??
+      (requestedModelSelection !== undefined
+        ? toProviderModelOptions(requestedModelSelection)
+        : undefined);
     const effectiveCwd = resolveThreadWorkspaceCwd({
       thread,
       projects: readModel.projects,

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
@@ -643,21 +643,14 @@
   );
   const selectedPromptEffort = composerProviderState.promptEffort;
   const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
-  const selectedModelSelection = useMemo<ModelSelection>(
-    () => ({
+  const selectedModelSelection = useMemo<ModelSelection>(() => {
+    const options = extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch);
+    return {
       provider: selectedProvider,
       model: selectedModel,
-      ...(extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch)
-        ? {
-            options: extractModelSelectionOptions(
-              selectedProvider,
-              selectedModelOptionsForDispatch,
-            ),
-          }
-        : {}),
-    }),
-    [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
-  );
+      ...(options ? { options } : {}),
+    };
+  }, [selectedModel, selectedModelOptionsForDispatch, selectedProvider]);
   const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
   const selectedModelForPicker = selectedModel;
   const modelOptionsByProvider = useMemo(

- Add migration for legacy provider/model payloads
- Cover project, thread, and orchestration event records
Copy link
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.

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

Autofix Details

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

  • ✅ Fixed: Dead provider parameter in startProviderSession closure
    • Removed the unused readonly provider?: ProviderKind field from the startProviderSession closure's input type, since the body only uses the outer preferredProvider variable.

Create PR

Or push these changes by commenting:

@cursor push e7ae3f40f4
Preview (e7ae3f40f4)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -260,10 +260,7 @@
         .listSessions()
         .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)));
 
-    const startProviderSession = (input?: {
-      readonly resumeCursor?: unknown;
-      readonly provider?: ProviderKind;
-    }) =>
+    const startProviderSession = (input?: { readonly resumeCursor?: unknown }) =>
       providerService.startSession(threadId, {
         threadId,
         ...(preferredProvider ? { provider: preferredProvider } : {}),

- Replace provider-specific model options with typed modelSelection
- Persist and replay selected provider, model, and options end to end
- Update adapter, service, and chat composer tests for the new shape
Copy link
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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Broader comparison causes unnecessary claudeAgent session restarts
    • Changed sameModelSelectionOptions to compare only left?.options and right?.options instead of the full ModelSelection, so model-name-only changes no longer trigger unnecessary session restarts for claudeAgent.

Create PR

Or push these changes by commenting:

@cursor push de46c2f9bd
Preview (de46c2f9bd)
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -78,7 +78,7 @@
 const sameModelSelectionOptions = (
   left: ModelSelection | undefined,
   right: ModelSelection | undefined,
-): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
+): boolean => JSON.stringify(left?.options ?? null) === JSON.stringify(right?.options ?? null);
 
 function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
   const error = Cause.squash(cause);

- Switch project and thread fixtures to provider-scoped model selection
- Keep sidebar logic tests aligned with the new Codex provider model shape
Copy link
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: sendTurn overwrites runtimePayload losing persisted model selection
    • Fixed by reading the existing binding's runtimePayload and spreading it as the base object before applying sendTurn-specific fields, so previously persisted fields like cwd, model, and providerOptions are preserved.

Create PR

Or push these changes by commenting:

@cursor push 2c97a21b07
Preview (2c97a21b07)
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -368,12 +368,20 @@
           allowRecovery: true,
         });
         const turn = yield* routed.adapter.sendTurn(input);
+        const existingBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId));
+        const existingPayload =
+          existingBinding?.runtimePayload &&
+          typeof existingBinding.runtimePayload === "object" &&
+          !Array.isArray(existingBinding.runtimePayload)
+            ? existingBinding.runtimePayload
+            : {};
         yield* directory.upsert({
           threadId: input.threadId,
           provider: routed.adapter.provider,
           status: "running",
           ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
           runtimePayload: {
+            ...existingPayload,
             ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
             activeTurnId: turn.turnId,
             lastRuntimeEvent: "provider.sendTurn",

Copy link
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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Log message always shows "undefined" in production
    • Conditionally formatted the log message to show the migration range only when toMigrationInclusive is defined, otherwise logging a generic "Running migrations..." message.

Create PR

Or push these changes by commenting:

@cursor push 919971ae24
Preview (919971ae24)
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -90,7 +90,11 @@
  */
 export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) =>
   Effect.gen(function* () {
-    yield* Effect.log(`Running migrations 1 through ${toMigrationInclusive}...`);
+    yield* Effect.log(
+      toMigrationInclusive !== undefined
+        ? `Running migrations 1 through ${toMigrationInclusive}...`
+        : "Running migrations...",
+    );
     const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
     yield* Effect.log("Migrations ran successfully");
     return executedMigrations;

- keep the active Codex model across turns when in-session switching is unavailable
- adjust reactor coverage for unsupported session model switching
- Store Codex and Claude composer traits separately
- Keep sticky and draft model options in sync across provider switches
- Update registry and picker tests for provider-scoped options
- keep default-state overrides in model selection updates
- retain provider model options instead of clearing them when values normalize
- Persist model options as provider-specific JSON
- Handle legacy fallback defaults during migration
- Update composer and picker tests for split selections
- Log when all migrations run
- Preserve project default model selection in sidebar test helper
stickyModelSelection,
nextStickyModelOptions,
);
const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/composerDraftStore.ts:975

In migratePersistedComposerDraftStoreState, stickyActiveProvider is derived only from candidate.stickyProvider (line 975), ignoring the provider in successfully migrated stickyModelSelection. For v2 persisted data that has stickyModelSelection (with an embedded provider like claudeAgent) but no separate stickyProvider field, the migration will incorrectly set stickyActiveProvider to null instead of using stickyModelSelection.provider. The fix should be: const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? stickyModelSelection?.provider ?? null;

Suggested change
const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? stickyModelSelection?.provider ?? null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/composerDraftStore.ts around line 975:

In `migratePersistedComposerDraftStoreState`, `stickyActiveProvider` is derived only from `candidate.stickyProvider` (line 975), ignoring the provider in successfully migrated `stickyModelSelection`. For v2 persisted data that has `stickyModelSelection` (with an embedded provider like `claudeAgent`) but no separate `stickyProvider` field, the migration will incorrectly set `stickyActiveProvider` to `null` instead of using `stickyModelSelection.provider`. The fix should be: `const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? stickyModelSelection?.provider ?? null;`

Evidence trail:
apps/web/src/composerDraftStore.ts line 975: `const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;`
apps/web/src/composerDraftStore.ts lines 958-967: Shows `normalizedStickyModelSelection` and `stickyModelSelection` being created with provider from `candidate.stickyModelSelection`
apps/web/src/composerDraftStore.ts lines 497-528: `normalizeModelSelection` uses `candidate?.provider ?? legacy?.provider`, meaning it extracts provider from `stickyModelSelection.provider` first
apps/web/src/composerDraftStore.ts lines 116-130: Shows `LegacyStickyModelFields` has `stickyProvider` (optional) and `LegacyV2StoreFields` has `stickyModelSelection` (optional) as separate fields, confirming v2 data can have embedded provider without separate `stickyProvider`

Copy link
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: Duplicate DB row schemas risk future inconsistency
    • Exported the DB row schemas from ProjectionProjects.ts and ProjectionThreads.ts and reused them in ProjectionSnapshotQuery.ts, eliminating the duplicated mapFields definitions.

Create PR

Or push these changes by commenting:

@cursor push 896a4631c9
Preview (896a4631c9)
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -6,7 +6,6 @@
   OrchestrationCheckpointFile,
   OrchestrationProposedPlanId,
   OrchestrationReadModel,
-  ProjectScript,
   ThreadId,
   TurnId,
   type OrchestrationCheckpointSummary,
@@ -17,7 +16,6 @@
   type OrchestrationSession,
   type OrchestrationThread,
   type OrchestrationThreadActivity,
-  ModelSelection,
 } from "@t3tools/contracts";
 import { Effect, Layer, Schema, Struct } from "effect";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
@@ -29,8 +27,9 @@
   toPersistenceSqlError,
   type ProjectionRepositoryError,
 } from "../../persistence/Errors.ts";
+import { ProjectionProjectDbRow } from "../../persistence/Layers/ProjectionProjects.ts";
+import { ProjectionThreadDbRow } from "../../persistence/Layers/ProjectionThreads.ts";
 import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
-import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
 import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
 import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
 import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
@@ -44,12 +43,7 @@
 } from "../Services/ProjectionSnapshotQuery.ts";
 
 const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
-const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
-  Struct.assign({
-    defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
-    scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
-  }),
-);
+const ProjectionProjectDbRowSchema = ProjectionProjectDbRow;
 const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
   Struct.assign({
     isStreaming: Schema.Number,
@@ -57,11 +51,7 @@
   }),
 );
 const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
-const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
-  Struct.assign({
-    modelSelection: Schema.fromJsonString(ModelSelection),
-  }),
-);
+const ProjectionThreadDbRowSchema = ProjectionThreadDbRow;
 const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
   Struct.assign({
     payload: Schema.fromJsonString(Schema.Unknown),

diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts
--- a/apps/server/src/persistence/Layers/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts
@@ -12,7 +12,7 @@
   type ProjectionProjectRepositoryShape,
 } from "../Services/ProjectionProjects.ts";
 
-const ProjectionProjectDbRow = ProjectionProject.mapFields(
+export const ProjectionProjectDbRow = ProjectionProject.mapFields(
   Struct.assign({
     defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
     scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),

diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts
--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts
@@ -13,7 +13,7 @@
 } from "../Services/ProjectionThreads.ts";
 import { ModelSelection } from "@t3tools/contracts";
 
-const ProjectionThreadDbRow = ProjectionThread.mapFields(
+export const ProjectionThreadDbRow = ProjectionThread.mapFields(
   Struct.assign({
     modelSelection: Schema.fromJsonString(ModelSelection),
   }),

Struct.assign({
modelSelection: Schema.fromJsonString(ModelSelection),
}),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Duplicate DB row schemas risk future inconsistency

Medium Severity

The DB-row decode schemas for projects and threads (ProjectionProjectDbRowSchema/ProjectionProjectDbRow and ProjectionThreadDbRowSchema/ProjectionThreadDbRow) are defined identically in both ProjectionSnapshotQuery.ts and their respective repository files (ProjectionProjects.ts, ProjectionThreads.ts). Each independently applies mapFields with the same Schema.fromJsonString(ModelSelection) transform. If one definition changes without updating the other, the two read paths will silently decode differently, risking data corruption or runtime errors.

Additional Locations (2)
Fix in Cursor Fix in Web

@juliusmarminge juliusmarminge merged commit a542a3b into main Mar 25, 2026
11 checks passed
@juliusmarminge juliusmarminge deleted the t3code/provider-kind-model branch March 25, 2026 05:40
Marve10s added a commit to Marve10s/t3code that referenced this pull request Mar 25, 2026
Port ultrathink effort-switching fix to new unified TraitsPicker component
after upstream PR pingdotgg#1371 merged ClaudeTraitsPicker into TraitsPicker.
emrezeytin pushed a commit to emrezeytin/t3code that referenced this pull request Mar 25, 2026
bulbulogludemir added a commit to bulbulogludemir/krabbycode that referenced this pull request Mar 26, 2026
…ings architecture

Major upstream changes merged:
- Server-authoritative settings (pingdotgg#1421): appSettings.ts deleted, settings
  moved to serverSettings.ts + contracts/settings.ts + useSettings hook
- Provider-aware model selections (pingdotgg#1371): model→modelSelection refactor,
  new composerDraftStore, unified TraitsPicker, migration 016
- Context window usage UI (pingdotgg#1351): ContextWindowMeter component
- Claude as TextGenerationProvider (pingdotgg#1323): git text generation via Claude
- Bootstrap FD for security (pingdotgg#1398): sensitive config sent over file descriptor
- Sidebar recency sorting (pingdotgg#1372): projects/threads sorted by recency
- ProviderHealth replaced by ProviderRegistry + per-provider services
- Word wrapping, terminal history preservation, various fixes

Krabby-specific adaptations:
- Migration 016 renumbered to 024 (Krabby 016-023 preserved)
- 20+ Krabby settings ported to new ClientSettingsSchema
- KrabbyProviderOptions schema for chrome/enterprise/budget fields
- All @t3tools@kRaBby, t3code→krabbycode, service keys t3/→krabby/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

1 participant